diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 31bacd1ba7..dad84571ea 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -163,7 +163,7 @@ class ChatTagsModel: ObservableObject { func markChatTagRead(_ chat: Chat) -> Void { if chat.unreadTag, let tags = chat.chatInfo.chatTags { - markChatTagRead_(chat, tags) + decTagsReadCount(tags) } } @@ -175,11 +175,11 @@ class ChatTagsModel: ObservableObject { unreadTags[tag] = (unreadTags[tag] ?? 0) + 1 } } else if !nowUnread && wasUnread { - markChatTagRead_(chat, tags) + decTagsReadCount(tags) } } - private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void { + func decTagsReadCount(_ tags: [Int64]) -> Void { for tag in tags { if let count = unreadTags[tag] { unreadTags[tag] = max(0, count - 1) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 117a8fa795..36a98e3f2f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -748,7 +748,11 @@ private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> V await MainActor.run { let m = ChatModel.shared - ChatTagsModel.shared.userTags = userTags + let tm = ChatTagsModel.shared + tm.userTags = userTags + if chat.unreadTag, let tags = chat.chatInfo.chatTags { + tm.decTagsReadCount(tags) + } if var contact = chat.chatInfo.contact { contact.chatTags = chatTags m.updateContact(contact) @@ -756,6 +760,7 @@ private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> V group.chatTags = chatTags m.updateGroup(group) } + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false) closeSheet() } } catch let error { 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/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ef98ddc678..6ac6a8f9fb 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-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.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 */; }; @@ -517,9 +517,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-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.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 = ""; }; @@ -673,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */, ); path = Libraries; sourceTree = ""; @@ -1935,7 +1935,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 258; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1960,7 +1960,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1984,7 +1984,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 258; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2009,7 +2009,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2025,11 +2025,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2045,11 +2045,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2070,7 +2070,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 = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2085,7 +2085,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2107,7 +2107,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 = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2122,7 +2122,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2144,7 +2144,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 = 258; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2170,7 +2170,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2195,7 +2195,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 = 258; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2221,7 +2221,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2246,7 +2246,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 = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2261,7 +2261,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2280,7 +2280,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 = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2295,7 +2295,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b6a8edb201..7459afe4f9 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1324,7 +1324,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?) @@ -1398,7 +1398,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) @@ -1431,7 +1431,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/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 67bc0d70c8..bb6a6f8f8a 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -27,6 +27,14 @@ + + + + + + + + = if (Build.VERSION.SDK_INT >= 33) { +// pm.queryIntentActivities(openIntent, PackageManager.ResolveInfoFlags.of((PackageManager.MATCH_DEFAULT_ONLY).toLong())) +// } else { +// pm.queryIntentActivities(openIntent, PackageManager.MATCH_DEFAULT_ONLY) +// }.sortedBy { it.priority } +// val first = resInfoList.firstOrNull { it.isDefault } ?: resInfoList.firstOrNull() ?: return null + val act = pm.resolveActivity(openIntent, PackageManager.MATCH_DEFAULT_ONLY) ?: return null +// Log.d(TAG, "Default launch action ${act} ${act.loadLabel(pm)} ${act.activityInfo?.name}") + val label = act.loadLabel(pm).toString() + val icon = act.loadIcon(pm).toBitmap().asImageBitmap() + val chooser = act.activityInfo?.name?.endsWith("ResolverActivity") == true + return OpenDefaultApp(label, icon, chooser) } actual fun shareFile(text: String, fileSource: CryptoFile) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt new file mode 100644 index 0000000000..b24150ed24 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -0,0 +1,57 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import chat.simplex.common.model.CryptoFile +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.DefaultDropdownMenu +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.net.URI + +@Composable +actual fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) { + val defaultApp = remember(encryptedUri.toString()) { if (ext != null) queryDefaultAppForExtension(ext, encryptedUri) else null } + DefaultDropdownMenu(showMenu) { + if (defaultApp != null) { + if (!defaultApp.isSystemChooser) { + ItemAction( + stringResource(MR.strings.open_with_app).format(defaultApp.name), + defaultApp.icon, + textColor = MaterialTheme.colors.primary, + onClick = { + openOrShareFile("", fileSource, justOpen = true, useChooser = false) + showMenu.value = false + } + ) + } else { + ItemAction( + stringResource(MR.strings.open_with_app).format("…"), + painterResource(MR.images.ic_open_in_new), + color = MaterialTheme.colors.primary, + onClick = { + openOrShareFile("", fileSource, justOpen = true, useChooser = false) + showMenu.value = false + } + ) + } + } + ItemAction( + stringResource(MR.strings.save_verb), + painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download), + color = MaterialTheme.colors.primary, + onClick = { + saveFile() + showMenu.value = false + } + ) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt new file mode 100644 index 0000000000..ab6d375d75 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt @@ -0,0 +1,81 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import android.view.ViewGroup +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.emoji2.emojipicker.EmojiPickerView +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + Box(Modifier + .clip(shape = CircleShape) + .clickable { + ModalManager.start.showModalCloseable { close -> + EmojiPicker(close = { + close() + emoji.value = it + }) + } + } + .padding(4.dp) + ) { + val emojiValue = emoji.value + if (emojiValue != null) { + Text(emojiValue) + } else { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } + } + Spacer(Modifier.width(8.dp)) + TagListNameTextField(name, showError = showError) + } +} + +@Composable +private fun EmojiPicker(close: (String?) -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val topPaddingToContent = topPaddingToContent(false) + + Column ( + modifier = Modifier.fillMaxSize().navigationBarsPadding().padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, + bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + ), + ) { + AndroidView( + factory = { context -> + EmojiPickerView(context).apply { + emojiGridColumns = 10 + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setOnEmojiPickedListener { pickedEmoji -> + close(pickedEmoji.emoji) + } + } + } + ) + } +} 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/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index db90c2e573..e2fe96e178 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 @@ -13,6 +13,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState @@ -81,6 +82,12 @@ object ChatModel { val groupMembers = mutableStateListOf() val groupMembersIndexes = mutableStateMapOf() + // Chat Tags + val userTags = mutableStateOf(emptyList()) + val activeChatTagFilter = mutableStateOf(null) + val presetTags = mutableStateMapOf() + val unreadTags = mutableStateMapOf() + // false: default placement, true: floating window. // Used for deciding to add terminal items on main thread or not. Floating means appPrefs.terminalAlwaysVisible var terminalsVisible = setOf() @@ -196,6 +203,116 @@ object ChatModel { } } + fun updateChatTags(rhId: Long?) { + val newPresetTags = mutableMapOf() + val newUnreadTags = mutableMapOf() + + for (chat in chats.value.filter { it.remoteHostId == rhId }) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chat.chatInfo)) { + newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1 + } + } + if (chat.unreadTag) { + val chatTags: List = when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> cInfo.contact.chatTags + is ChatInfo.Group -> cInfo.groupInfo.chatTags + else -> emptyList() + } + chatTags.forEach { tag -> + newUnreadTags[tag] = (newUnreadTags[tag] ?: 0) + 1 + } + } + } + + if (activeChatTagFilter.value is ActiveFilter.PresetTag && + (newPresetTags[(activeChatTagFilter.value as ActiveFilter.PresetTag).tag] ?: 0) == 0) { + activeChatTagFilter.value = null + } + + presetTags.clear() + presetTags.putAll(newPresetTags) + unreadTags.clear() + unreadTags.putAll(newUnreadTags) + } + + fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) { + val count = presetTags[PresetTagKind.FAVORITES] + + if (favorite && !wasFavorite) { + presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1 + } else if (!favorite && wasFavorite && count != null) { + presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1) + if (activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.FAVORITES) && (presetTags[PresetTagKind.FAVORITES] ?: 0) == 0) { + activeChatTagFilter.value = null + } + } + } + + fun addPresetChatTags(chatInfo: ChatInfo) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chatInfo)) { + presetTags[tag] = (presetTags[tag] ?: 0) + 1 + } + } + } + + fun removePresetChatTags(chatInfo: ChatInfo) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chatInfo)) { + val count = presetTags[tag] + if (count != null) { + presetTags[tag] = maxOf(0, count - 1) + } + } + } + } + + fun markChatTagRead(chat: Chat) { + if (chat.unreadTag) { + chat.chatInfo.chatTags?.let { tags -> + markChatTagRead_(chat, tags) + } + } + } + + fun updateChatTagRead(chat: Chat, wasUnread: Boolean) { + val tags = chat.chatInfo.chatTags ?: return + val nowUnread = chat.unreadTag + + if (nowUnread && !wasUnread) { + tags.forEach { tag -> + unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 + } + } else if (!nowUnread && wasUnread) { + markChatTagRead_(chat, tags) + } + } + + fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { + if (chat.unreadTag) { + oldTags?.forEach { t -> + val oldCount = unreadTags[t] + if (oldCount != null) { + unreadTags[t] = maxOf(0, oldCount - 1) + } + } + + newTags.forEach { t -> + unreadTags[t] = (unreadTags[t] ?: 0) + 1 + } + } + } + + private fun markChatTagRead_(chat: Chat, tags: List) { + for (tag in tags) { + val count = unreadTags[tag] + if (count != null) { + unreadTags[tag] = maxOf(0, count - 1) + } + } + } + // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null // TODO pass rhId? @@ -280,6 +397,7 @@ object ChatModel { updateChatInfo(rhId, cInfo) } else if (addMissing) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) + addPresetChatTags(cInfo) } } @@ -329,6 +447,7 @@ object ChatModel { } else -> cItem } + val wasUnread = chat.unreadTag chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = @@ -339,6 +458,8 @@ object ChatModel { else chat.chatStats ) + updateChatTagRead(chats[i], wasUnread) + if (appPlatform.isDesktop && cItem.chatDir.sent) { reorderChat(chats[i], 0) } else { @@ -455,6 +576,7 @@ object ChatModel { if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) + markChatTagRead(chats[i]) } // clear current chat if (chatId.value == cInfo.id) { @@ -522,11 +644,13 @@ object ChatModel { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { + val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount) ) + updateChatTagRead(chats[chatIdx], wasUnread) } } } @@ -537,16 +661,29 @@ object ChatModel { val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) + val wasUnread = chat.unreadTag decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, ) ) + updateChatTagRead(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { - chats.removeAll { it.id == id && it.remoteHostId == rhId } + var removed: ChatInfo? = null + chats.removeAll { + val found = it.id == id && it.remoteHostId == rhId + if (found) { + removed = it.chatInfo + } + found + } + + removed?.let { + removePresetChatTags(it) + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -977,6 +1114,8 @@ data class Chat( else -> false } + val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + val id: String get() = chatInfo.id fun groupFeatureEnabled(feature: GroupFeature): Boolean = @@ -1189,6 +1328,12 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> false } + val chatTags: List? + get() = when (this) { + is Direct -> contact.chatTags + is Group -> groupInfo.chatTags + else -> null + } } @Serializable @@ -1232,6 +1377,7 @@ data class Contact( val chatTs: Instant?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, + val chatTags: List, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { @@ -1315,6 +1461,7 @@ data class Contact( contactGrpInvSent = false, chatDeleted = false, uiThemes = null, + chatTags = emptyList() ) } } @@ -1476,6 +1623,7 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, + val chatTags: List ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -1520,6 +1668,7 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, + chatTags = emptyList() ) } } @@ -3850,6 +3999,13 @@ sealed class ChatItemTTL: Comparable { } } +@Serializable +data class ChatTag( + val chatTagId: Long, + val chatTagText: String, + val chatTagEmoji: String? +) + @Serializable class ChatItemInfo( val itemVersions: List, 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 95d5a01699..a86be622b9 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" @@ -624,6 +626,9 @@ object ChatController { val chats = apiGetChats(rhId) updateChats(chats) } + chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() + chatModel.activeChatTagFilter.value = null + chatModel.updateChatTags(rhId) } private fun startReceiver() { @@ -879,6 +884,16 @@ object ChatController { return emptyList() } + private suspend fun apiGetChatTags(rh: Long?): List?{ + val userId = currentUserId("apiGetChatTags") + val r = sendCmd(rh, CC.ApiGetChatTags(userId)) + + if (r is CR.ChatTags) return r.userTags + Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}") + return null + } + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo @@ -891,6 +906,28 @@ object ChatController { return null } + suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List? { + val r = sendCmd(rh, CC.ApiCreateChatTag(tag)) + if (r is CR.ChatTags) return r.userTags + Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List): Pair, List>? { + val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds)) + if (r is CR.TagsUpdated) return r.userTags to r.chatTags + Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiDeleteChatTag(rh: Long?, tagId: Long) = sendCommandOkResp(rh, CC.ApiDeleteChatTag(tagId)) + + suspend fun apiUpdateChatTag(rh: Long?, tagId: Long, tag: ChatTagData) = sendCommandOkResp(rh, CC.ApiUpdateChatTag(tagId, tag)) + + suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) @@ -3152,10 +3189,16 @@ sealed class CC { class TestStorageEncryption(val key: String): CC() class ApiSaveSettings(val settings: AppSettings): CC() class ApiGetSettings(val settings: AppSettings): CC() + class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): 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 ApiCreateChatTag(val tag: ChatTagData): CC() + class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() + class ApiDeleteChatTag(val tagId: Long): CC() + class ApiUpdateChatTag(val tagId: Long, val tagData: ChatTagData): CC() + class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): 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() @@ -3307,6 +3350,7 @@ sealed class CC { is TestStorageEncryption -> "/db test key $key" is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}" is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" + is ApiGetChatTags -> "/_get tags $userId" is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" @@ -3315,6 +3359,11 @@ sealed class CC { val ttlStr = if (ttl != null) "$ttl" else "default" "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs" } + is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" + is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}" + is ApiDeleteChatTag -> "/_delete tag $tagId" + is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}" + is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}" is ApiCreateChatItems -> { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" @@ -3471,10 +3520,16 @@ sealed class CC { is TestStorageEncryption -> "testStorageEncryption" is ApiSaveSettings -> "apiSaveSettings" is ApiGetSettings -> "apiGetSettings" + is ApiGetChatTags -> "apiGetChatTags" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" + is ApiCreateChatTag -> "apiCreateChatTag" + is ApiSetChatTags -> "apiSetChatTags" + is ApiDeleteChatTag -> "apiDeleteChatTag" + is ApiUpdateChatTag -> "apiUpdateChatTag" + is ApiReorderChatTags -> "apiReorderChatTags" is ApiCreateChatItems -> "apiCreateChatItems" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" @@ -3657,6 +3712,9 @@ sealed class ChatPagination { @Serializable class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +@Serializable +class ChatTagData(val emoji: String?, val text: String) + @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) @@ -3757,7 +3815,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 +3859,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 +3941,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) @@ -5390,6 +5448,7 @@ sealed class CR { @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() + @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @@ -5416,6 +5475,7 @@ sealed class CR { @Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() + @Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List, val chatTags: List): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() @@ -5574,6 +5634,7 @@ sealed class CR { is ChatStopped -> "chatStopped" is ApiChats -> "apiChats" is ApiChat -> "apiChat" + is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" is ServerOperatorConditions -> "serverOperatorConditions" @@ -5600,6 +5661,7 @@ sealed class CR { is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" + is TagsUpdated -> "tagsUpdated" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is ConnectionUserChanged -> "ConnectionUserChanged" @@ -5748,6 +5810,7 @@ sealed class CR { is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") + is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" @@ -5774,6 +5837,7 @@ sealed class CR { is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") + is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}") is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index ed661245a3..9b580edb62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -697,13 +697,19 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) } ) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(contact.profile.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( text, inlineContent = inlineContent, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center, maxLines = 3, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) { Text( @@ -711,7 +717,8 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } 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..c58561718e 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 @@ -31,6 +31,7 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.markChatTagRead import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -2106,6 +2107,7 @@ private fun markUnreadChatAsRead(chatId: String) { if (success) { withChats { replaceChat(chatRh, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + markChatTagRead(chat) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 5ee6e40e6e..c92ac2ddc3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -9,6 +9,7 @@ import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* @@ -17,6 +18,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight @@ -446,12 +449,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) { horizontalAlignment = Alignment.CenterHorizontally ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(cInfo.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { Text( @@ -459,7 +468,8 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 8, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } 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 e4e5494364..e607efeddc 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 @@ -8,6 +8,7 @@ import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -537,13 +538,19 @@ fun GroupMemberInfoHeader(member: GroupMember) { Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) } ) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(member.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( text, inlineContent = inlineContent, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center, maxLines = 3, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (member.fullName != "" && member.fullName != member.displayName) { Text( @@ -551,7 +558,8 @@ fun GroupMemberInfoHeader(member: GroupMember) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 59643afdf4..2c16de40e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,12 +1,12 @@ package chat.simplex.common.views.chat.item -import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -184,14 +184,26 @@ fun CIFileView( } } + val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } + val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + if (loadedFilePath != null && file?.fileSource != null) { + val encrypted = file.fileSource.cryptoArgs != null + SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) + } Row( Modifier .combinedClickable( - onClick = { fileAction() }, + onClick = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + }, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), - //Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.sp.toDp()) ) { @@ -223,6 +235,16 @@ fun CIFileView( fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +@Composable +expect fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) + @Composable fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> 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 22842eb350..647c74da06 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 @@ -867,6 +867,32 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on } } +@Composable +fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) { + val finalColor = if (textColor == Color.Unspecified) { + MenuTextColor + } else textColor + DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding(end = 15.dp), + color = finalColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (iconColor == Color.Unspecified) { + Image(icon, text, Modifier.size(22.dp)) + } else { + Icon(icon, text, Modifier.size(22.dp), tint = iconColor) + } + } + } +} + @Composable fun ItemAction( text: String, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 226030fcd4..2f0311b087 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -4,28 +4,34 @@ import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity 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.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.* +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.* import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.markChatTagRead +import chat.simplex.common.model.ChatModel.updateChatTagRead 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.deleteGroupDialog -import chat.simplex.common.views.chat.group.leaveGroupDialog +import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* @@ -33,7 +39,6 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock -import kotlin.math.min @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { @@ -252,6 +257,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } DeleteContactAction(chat, chatModel, showMenu) @@ -291,6 +297,7 @@ fun GroupMenuItems( } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) @@ -337,6 +344,28 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat ) } +@Composable +fun TagListAction( + chat: Chat, + showMenu: MutableState +) { + val userTags = remember { chatModel.userTags } + ItemAction( + stringResource(MR.strings.list_menu), + painterResource(MR.images.ic_label), + onClick = { + ModalManager.start.showModalCloseable { close -> + if (userTags.value.isEmpty()) { + TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close) + } else { + TagListView(rhId = chat.remoteHostId, chat = chat, close = close) + } + } + showMenu.value = false + } + ) +} + @Composable fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState) { ItemAction( @@ -557,6 +586,7 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + markChatTagRead(chat) } } } @@ -568,6 +598,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (chat.chatStats.unreadChat) return withApi { + val wasUnread = chat.unreadTag val success = chatModel.controller.apiChatUnread( chat.remoteHostId, chat.chatInfo.chatType, @@ -577,6 +608,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + updateChatTagRead(chat, wasUnread) } } } @@ -826,12 +858,20 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch else -> false } if (res && newChatInfo != null) { + val chat = chatModel.getChat(chatInfo.id) + val wasUnread = chat?.unreadTag ?: false + val wasFavorite = chatInfo.chatSettings?.favorite ?: false + chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite) withChats { updateChatInfo(remoteHostId, newChatInfo) } if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chatInfo.id) } + val updatedChat = chatModel.getChat(chatInfo.id) + if (updatedChat != null) { + chatModel.updateChatTagRead(updatedChat, wasUnread) + } val current = currentState?.value if (current != null) { currentState.value = !current 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..4648ac5037 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 @@ -16,11 +16,13 @@ import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.platform.* -import androidx.compose.ui.text.TextRange +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.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* @@ -31,22 +33,30 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call -import chat.simplex.common.views.chat.item.CIFileViewScope +import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.topPaddingToContent -import chat.simplex.common.views.mkValidName import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* -import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds +enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS } + +sealed class ActiveFilter { + data class PresetTag(val tag: PresetTagKind) : ActiveFilter() + data class UserTag(val tag: ChatTag) : ActiveFilter() + data object Unread: ActiveFilter() +} + private fun showNewChatSheet(oneHandUI: State) { ModalManager.start.closeModals() ModalManager.end.closeModals() @@ -187,6 +197,12 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, listStat val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { val currentIndex = listState.firstVisibleItemIndex @@ -753,14 +777,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } - val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value val allChats = remember { chatModel.chats } // In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side // which is related to [derivedStateOf]. Using safe alternative instead // val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } } val searchShowingSimplexLink = remember { mutableStateOf(false) } val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } - val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList()) + val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value) val topPaddingToContent = topPaddingToContent(false) val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent LazyColumnWithScrollBar( @@ -791,11 +814,15 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) { if (oneHandUI.value) { Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { + Divider() + TagsView() ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) + TagsView() + Divider() } } } @@ -815,8 +842,8 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) { - Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) { - Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary) + Box(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + NoChatsView(searchText = searchText) } } if (oneHandUI.value) { @@ -839,6 +866,41 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } } + + LaunchedEffect(activeFilter.value) { + searchText.value = TextFieldValue("") + } +} + +@Composable +private fun NoChatsView(searchText: MutableState) { + val activeFilter = remember { chatModel.activeChatTagFilter }.value + + if (searchText.value.text.isBlank()) { + when (activeFilter) { + is ActiveFilter.PresetTag -> Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) // this should not happen + is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + is ActiveFilter.Unread -> { + Row( + Modifier.clip(shape = CircleShape).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_filter_list), + null, + tint = MaterialTheme.colors.secondary + ) + Text(generalGetString(MR.strings.no_unread_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + null -> { + Text(generalGetString(MR.strings.no_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + } else { + Text(generalGetString(MR.strings.no_chats_found), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } } @Composable @@ -860,31 +922,301 @@ private fun ChatListFeatureCards() { } } +private val TAG_MIN_HEIGHT = 35.dp + +@Composable +private fun TagsView() { + val userTags = remember { chatModel.userTags } + val presetTags = remember { chatModel.presetTags } + val activeFilter = remember { chatModel.activeChatTagFilter } + val unreadTags = remember { chatModel.unreadTags } + val rhId = chatModel.remoteHostId() + + fun showTagList() { + ModalManager.start.showCustomModal { close -> + val editMode = remember { stateGetOrPut("editMode") { false } } + ModalView(close, showClose = true, endButtons = { + TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) { + Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb)) + } + }) { + TagListView(rhId = rhId, close = close, editMode = editMode) + } + } + } + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + + TagsRow { + if (presetTags.size > 1) { + if (presetTags.size + userTags.value.size <= 3) { + PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag -> + ExpandedTagFilterView(tag) + } + } else { + CollapsedTagsFilterView() + } + } + + userTags.value.forEach { tag -> + val current = when (val af = activeFilter.value) { + is ActiveFilter.UserTag -> af.tag == tag + else -> false + } + val interactionSource = remember { MutableInteractionSource() } + Row( + rowSizeModifier + .clip(shape = CircleShape) + .combinedClickable( + onClick = { + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + } + }, + onLongClick = { showTagList() }, + interactionSource = interactionSource, + indication = LocalIndication.current + ) + .onRightClick { showTagList() } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) + } else { + Icon( + painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), + null, + Modifier.size(18.sp.toDp()), + tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Box { + val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" + val invisibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { + append(badgeText) + } + } + Text( + text = invisibleText, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + color = Color.Transparent, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Visible text with styles + val visibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { + append(badgeText) + } + } + Text( + text = visibleText, + fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp, + color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + val plusClickModifier = Modifier + .clickable { + ModalManager.start.showModalCloseable { close -> + TagListEditor(rhId = rhId, close = close) + } + } + + if (userTags.value.isEmpty()) { + Row(rowSizeModifier.clip(shape = CircleShape).then(plusClickModifier).padding(start = 2.dp, top = 4.dp, end = 6.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(2.dp)) + Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary, fontSize = 15.sp) + } + } else { + Box(rowSizeModifier, contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(2.dp), tint = MaterialTheme.colors.secondary + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagsRow(content: @Composable() (() -> Unit)) { + if (appPlatform.isAndroid) { + Row( + modifier = Modifier + .padding(horizontal = 14.dp) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + } + } else { + FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } + } +} + +@Composable +private fun ExpandedTagFilterView(tag: PresetTagKind) { + val activeFilter = remember { chatModel.activeChatTagFilter } + val active = when (val af = activeFilter.value) { + is ActiveFilter.PresetTag -> af.tag == tag + else -> false + } + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + val (icon, text) = presetTagLabel(tag, active) + val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + + Row( + modifier = rowSizeModifier + .clip(shape = CircleShape) + .clickable { + if (activeFilter.value == ActiveFilter.PresetTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(tag) + } + } + .padding(horizontal = 5.dp, vertical = 4.dp) + , + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(icon), + stringResource(text), + Modifier.size(18.sp.toDp()), + tint = color + ) + Spacer(Modifier.width(4.dp)) + Box { + Text( + stringResource(text), + color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + fontWeight = if (active) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp + ) + Text( + stringResource(text), + color = Color.Transparent, + fontWeight = FontWeight.Medium, + fontSize = 15.sp + ) + } + } +} + + +@Composable +private fun CollapsedTagsFilterView() { + val activeFilter = remember { chatModel.activeChatTagFilter } + val presetTags = remember { chatModel.presetTags } + val showMenu = remember { mutableStateOf(false) } + + val selectedPresetTag = when (val af = activeFilter.value) { + is ActiveFilter.PresetTag -> af.tag + else -> null + } + + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + Box(rowSizeModifier + .padding(vertical = 4.dp) + .clip(shape = CircleShape) + .size(30.sp.toDp()) + .clickable { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + if (selectedPresetTag != null) { + val (icon, text) = presetTagLabel(selectedPresetTag, true) + Icon( + painterResource(icon), + stringResource(text), + Modifier.size(18.sp.toDp()), + tint = MaterialTheme.colors.secondary + ) + } else { + Icon( + painterResource(MR.images.ic_menu), + stringResource(MR.strings.chat_list_all), + tint = MaterialTheme.colors.secondary + ) + } + + DefaultDropdownMenu(showMenu = showMenu) { + if (selectedPresetTag != null) { + ItemAction( + stringResource(MR.strings.chat_list_all), + painterResource(MR.images.ic_menu), + onClick = { + chatModel.activeChatTagFilter.value = null + showMenu.value = false + } + ) + } + PresetTagKind.entries.forEach { tag -> + if ((presetTags[tag] ?: 0) > 0) { + ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu) + } + } + } + } +} + +@Composable +fun ItemPresetFilterAction( + presetTag: PresetTagKind, + active: Boolean, + showMenu: MutableState +) { + val (icon, text) = presetTagLabel(presetTag, active) + ItemAction( + stringResource(text), + painterResource(icon), + onClick = { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + showMenu.value = false + } + ) +} + fun filteredChats( - showUnreadAndFavorites: Boolean, searchShowingSimplexLink: State, searchChatFilteredBySimplexLink: State, searchText: String, - chats: List + chats: List, + activeFilter: ActiveFilter? = null, ): List { val linkChatId = searchChatFilteredBySimplexLink.value return if (linkChatId != null) { chats.filter { it.id == linkChatId } } else { val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() - if (s.isEmpty() && !showUnreadAndFavorites) - chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD } + if (s.isEmpty()) + chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) } else { chats.filter { chat -> when (val cInfo = chat.chatInfo) { is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && ( if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) + chat.id == chatModel.chatId.value || filtered(chat, activeFilter) } else { cInfo.anyNameContains(s) }) is ChatInfo.Group -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited + chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited } else { cInfo.anyNameContains(s) } @@ -898,10 +1230,41 @@ fun filteredChats( } } -private fun filtered(chat: Chat): Boolean = - (chat.chatInfo.chatSettings?.favorite ?: false) || - chat.chatStats.unreadChat || - (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) +private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = + when (activeFilter) { + is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo) + is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false + is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + else -> true + } + +fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = + when (tag) { + PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true + PresetTagKind.CONTACTS -> when (chatInfo) { + is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted + is ChatInfo.ContactRequest -> true + is ChatInfo.ContactConnection -> true + is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Customer + else -> false + } + PresetTagKind.GROUPS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null + else -> false + } + PresetTagKind.BUSINESS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business + else -> false + } + } + +private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = + when (tag) { + PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites + PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts + PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups + PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses + } fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index e048c39fe7..aa9847c98a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -191,7 +191,7 @@ private fun ShareList( val chats by remember(search) { derivedStateOf { val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local } - filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted) + filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted) } } val topPaddingToContent = topPaddingToContent(false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt new file mode 100644 index 0000000000..2cd0c953c7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -0,0 +1,500 @@ +package chat.simplex.common.views.chatlist + +import SectionCustomFooter +import SectionDivider +import SectionItemView +import TextIconSpaced +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.* +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.apiDeleteChatTag +import chat.simplex.common.model.ChatController.apiSetChatTags +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.ReactionIcon +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState = remember { mutableStateOf(false) }) { + if (remember { editMode }.value) { + BackHandler { + editMode.value = false + } + } + val userTags = remember { chatModel.userTags } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState() + val saving = remember { mutableStateOf(false) } + val chatTagIds = derivedStateOf { chat?.chatInfo?.chatTags ?: emptyList() } + + fun reorderTags(tagIds: List) { + saving.value = true + withBGApi { + try { + chatModel.controller.apiReorderChatTags(rhId, tagIds) + } catch (e: Exception) { + Log.d(TAG, "ChatListTag reorderTags error: ${e.message}") + } finally { + saving.value = false + } + } + } + + val dragDropState = + rememberDragDropState(listState) { fromIndex, toIndex -> + userTags.value = userTags.value.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + reorderTags(userTags.value.map { it.chatTagId }) + } + val topPaddingToContent = topPaddingToContent(false) + + LazyColumnWithScrollBar( + modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier, + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, + bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + ), + state = listState, + verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top, + ) { + @Composable fun CreateList() { + SectionItemView({ + ModalManager.start.showModalCloseable { close -> + TagListEditor(rhId = rhId, close = close, chat = chat) + } + }) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.create_list), tint = MaterialTheme.colors.primary) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(MR.strings.create_list), color = MaterialTheme.colors.primary) + } + } + + if (oneHandUI.value && !editMode.value) { + item { + CreateList() + } + } + itemsIndexed(userTags.value, key = { _, item -> item.chatTagId }) { index, tag -> + DraggableItem(dragDropState, index) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Card( + elevation = elevation, + backgroundColor = if (isDragging) colors.surface else Color.Unspecified + ) { + Column { + val showMenu = remember { mutableStateOf(false) } + val selected = chatTagIds.value.contains(tag.chatTagId) + + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) + .combinedClickable( + enabled = !saving.value, + onClick = { + if (chat == null) { + ModalManager.start.showModalCloseable { close -> + TagListEditor( + rhId = rhId, + tagId = tag.chatTagId, + close = close, + emoji = tag.chatTagEmoji, + name = tag.chatTagText, + ) + } + } else { + saving.value = true + setTag(rhId = rhId, tagId = if (selected) null else tag.chatTagId, chat = chat, close = { + saving.value = false + close() + }) + } + }, + onLongClick = if (editMode.value) null else { + { showMenu.value = true } + }, + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ) + .onRightClick { showMenu.value = true } + .padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)), + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) + } else { + Icon(painterResource(MR.images.ic_label), null, Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.onBackground) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + tag.chatTagText, + color = MenuTextColor, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal + ) + if (selected) { + Spacer(Modifier.weight(1f)) + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } else if (editMode.value) { + Spacer(Modifier.weight(1f)) + Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) + } + DefaultDropdownMenu(showMenu, dropdownMenuItems = { + EditTagAction(rhId, tag, showMenu) + DeleteTagAction(rhId, tag, showMenu, saving) + }) + } + SectionDivider() + } + } + } + } + if (!oneHandUI.value && !editMode.value) { + item { + CreateList() + } + } + } +} + +@Composable +fun ModalData.TagListEditor( + rhId: Long?, + chat: Chat? = null, + tagId: Long? = null, + emoji: String? = null, + name: String = "", + close: () -> Unit +) { + val userTags = remember { chatModel.userTags } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val newEmoji = remember { stateGetOrPutNullable("chatTagEmoji") { emoji } } + val newName = remember { stateGetOrPut("chatTagName") { name } } + val saving = remember { mutableStateOf(null) } + val trimmedName = remember { derivedStateOf { newName.value.trim() } } + val isDuplicateEmojiOrName = remember { + derivedStateOf { + userTags.value.any { tag -> + tag.chatTagId != tagId && + ((newEmoji.value != null && tag.chatTagEmoji == newEmoji.value) || tag.chatTagText == trimmedName.value) + } + } + } + + fun createTag() { + saving.value = true + withBGApi { + try { + val updatedTags = chatModel.controller.apiCreateChatTag(rhId, ChatTagData(newEmoji.value, trimmedName.value)) + if (updatedTags != null) { + saving.value = false + userTags.value = updatedTags + close() + } else { + saving.value = null + return@withBGApi + } + + if (chat != null) { + val createdTag = updatedTags.firstOrNull() { it.chatTagText == trimmedName.value && it.chatTagEmoji == newEmoji.value } + + if (createdTag != null) { + setTag(rhId, createdTag.chatTagId, chat, close = { + saving.value = false + close() + }) + } + } + } catch (e: Exception) { + Log.d(TAG, "createChatTag tag error: ${e.message}") + saving.value = null + } + } + } + + fun updateTag() { + saving.value = true + withBGApi { + try { + if (chatModel.controller.apiUpdateChatTag(rhId, tagId!!, ChatTagData(newEmoji.value, trimmedName.value))) { + userTags.value = userTags.value.map { tag -> + if (tag.chatTagId == tagId) { + tag.copy(chatTagEmoji = newEmoji.value, chatTagText = trimmedName.value) + } else { + tag + } + } + } else { + saving.value = null + return@withBGApi + } + saving.value = false + close() + } catch (e: Exception) { + Log.d(TAG, "ChatListTagEditor updateChatTag tag error: ${e.message}") + saving.value = null + } + } + } + + val showError = derivedStateOf { isDuplicateEmojiOrName.value && saving.value != false } + + ColumnWithScrollBar(Modifier.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) WindowInsets.ime.asPaddingValues().calculateBottomPadding().coerceIn(0.dp, WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) else 0.dp))) { + if (oneHandUI.value) { + Spacer(Modifier.weight(1f)) + } + ChatTagInput(newName, showError, newEmoji) + val disabled = saving.value == true || + (trimmedName.value == name && newEmoji.value == emoji) || + trimmedName.value.isEmpty() || + isDuplicateEmojiOrName.value + + SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { + Text( + generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list), + color = if (disabled) colors.secondary else colors.primary + ) + } + val showErrorMessage = isDuplicateEmojiOrName.value && saving.value != false + SectionCustomFooter { + Row( + Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = stringResource(MR.strings.error), + tint = if (showErrorMessage) Color.Red else Color.Transparent, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + generalGetString(MR.strings.duplicated_list_error), + color = if (showErrorMessage) colors.secondary else Color.Transparent, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { + ItemAction( + stringResource(MR.strings.delete_chat_list_menu_action), + painterResource(MR.images.ic_delete), + onClick = { + deleteTagDialog(rhId, tag, saving) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.edit_chat_list_menu_action), + painterResource(MR.images.ic_edit), + onClick = { + showMenu.value = false + ModalManager.start.showModalCloseable { close -> + TagListEditor( + rhId = rhId, + tagId = tag.chatTagId, + close = close, + emoji = tag.chatTagEmoji, + name = tag.chatTagText + ) + } + }, + color = MenuTextColor + ) +} + +@Composable +expect fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) + +@Composable +fun TagListNameTextField(name: MutableState, showError: State) { + var focused by rememberSaveable { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ) + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .indicatorLine(true, showError.value, interactionSource, colors) + .heightIn(min = TextFieldDefaults.MinHeight) + .onFocusChanged { focused = it.isFocused } + .focusRequester(focusRequester), + textStyle = TextStyle(fontSize = 18.sp, color = MaterialTheme.colors.onBackground), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = name.value, + innerTextField = innerTextField, + placeholder = { + Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp)) + }, + contentPadding = PaddingValues(), + label = null, + visualTransformation = VisualTransformation.None, + leadingIcon = null, + singleLine = true, + enabled = true, + isError = false, + interactionSource = remember { MutableInteractionSource() }, + colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) + ) + } + ) +} + +private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) { + withBGApi { + val tagIds: List = if (tagId == null) { + emptyList() + } else { + listOf(tagId) + } + + try { + val result = apiSetChatTags(rh = rhId, type = chat.chatInfo.chatType, id = chat.chatInfo.apiId, tagIds = tagIds) + + if (result != null) { + val oldTags = chat.chatInfo.chatTags + chatModel.userTags.value = result.first + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> { + val contact = cInfo.contact.copy(chatTags = result.second) + withChats { + updateContact(rhId, contact) + } + } + + is ChatInfo.Group -> { + val group = cInfo.groupInfo.copy(chatTags = result.second) + withChats { + updateGroup(rhId, group) + } + } + + else -> {} + } + chatModel.moveChatTagUnread(chat, oldTags, result.second) + close() + } + } catch (e: Exception) { + Log.d(TAG, "setChatTag error: ${e.message}") + } + } +} + +private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) { + withBGApi { + saving.value = true + + try { + val tagId = tag.chatTagId + if (apiDeleteChatTag(rhId, tagId)) { + chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId } + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } + chatModel.chats.value.forEach { c -> + when (val cInfo = c.chatInfo) { + is ChatInfo.Direct -> { + val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId }) + withChats { + updateContact(rhId, contact) + } + } + is ChatInfo.Group -> { + val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId }) + withChats { + updateGroup(rhId, group) + } + } + else -> {} + } + } + } + + } catch (e: Exception) { + Log.d(TAG, "deleteTag error: ${e.message}") + } finally { + saving.value = false + } + } +} + +private fun deleteTagDialog(rhId: Long?, tag: ChatTag, saving: MutableState) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_chat_list_question), + text = String.format(generalGetString(MR.strings.delete_chat_list_warning), tag.chatTagText), + buttons = { + SectionItemView({ + AlertManager.shared.hideAlert() + deleteTag(rhId, tag, saving) + }) { + Text( + generalGetString(MR.strings.confirm_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = colors.error + ) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text( + stringResource(MR.strings.cancel_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = colors.primary + ) + } + } + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt new file mode 100644 index 0000000000..cded400892 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt @@ -0,0 +1,177 @@ +package chat.simplex.common.views.helpers + +/* + * This was adapted from google example of drag and drop for Jetpack Compose + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + */ + +import androidx.compose.animation.core.* +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +@Composable +fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState { + val scope = rememberCoroutineScope() + val state = + remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, scope = scope) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState +internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = + draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + val touchY = offset.y.toInt() + val item = state.layoutInfo.visibleItemsInfo.minByOrNull { + val itemCenter = (it.offset - state.layoutInfo.viewportStartOffset) + it.size / 2 + kotlin.math.abs(touchY - itemCenter) // Find the item closest to the touch position, needs to take viewportStartOffset into account + } + + if (item != null) { + draggingItemIndex = item.index + draggingItemInitialOffset = item.offset + } + } + + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = + state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset + ) + } + onMove.invoke(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index + } else { + val overscroll = + when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = + if (dragging) { + Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier.zIndex(1f).graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } +} 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 a209126a89..ffbe473df8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -188,6 +188,9 @@ Error updating user privacy Slow function Execution of function takes too long time: %1$d seconds: %2$s + Error updating chat list + Error creating chat list + Error loading chat lists Instant notifications @@ -361,6 +364,7 @@ Revoke Forward Download + List Message forwarded No direct connection yet, message is forwarded by admin. @@ -390,6 +394,10 @@ You have no chats Loading chats… No filtered chats + No chats in list %s. + No unread chats + No chats + No chats found Tap to Connect Connect with %1$s? Search or paste SimpleX link @@ -409,6 +417,12 @@ %1$d file(s) were deleted. Download %1$s messages not forwarded + Favorites + Contacts + Groups + Businesses + All + Add list Share message… @@ -482,6 +496,7 @@ Please, wait while the file is being loaded from the linked mobile File error Temporary file error + Open with %s Voice message @@ -626,6 +641,16 @@ Favorite Unfavorite + + Create list + Add to list + Save list + List name... + List name and emoji should be different for all lists. + Delete + Delete list? + All chats will be removed from the list %s, and the list deleted + Edit You invited a contact diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg new file mode 100644 index 0000000000..99d3e66fe8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg new file mode 100644 index 0000000000..f30bc0db2c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg new file mode 100644 index 0000000000..02c84c9d05 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg new file mode 100644 index 0000000000..3b58600ae2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg new file mode 100644 index 0000000000..0ed867b156 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg new file mode 100644 index 0000000000..fb5c122eec --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt new file mode 100644 index 0000000000..eceb7de9be --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.CryptoFile +import java.net.URI + +@Composable +actual fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) { + +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 9789fa3d1a..a1f70213d0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -4,8 +4,7 @@ import SectionDivider import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt new file mode 100644 index 0000000000..75a76014a9 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -0,0 +1,72 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex +import chat.simplex.common.views.chat.item.isHeartEmoji +import chat.simplex.common.views.chat.item.isShortEmoji +import chat.simplex.common.views.helpers.toDp +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SingleEmojiInput(emoji) + TagListNameTextField(name, showError = showError) + } +} + +@Composable +private fun SingleEmojiInput( + emoji: MutableState +) { + val state = remember { mutableStateOf(TextFieldValue(emoji.value ?: "")) } + val colors = TextFieldDefaults.textFieldColors( + textColor = if (isHeartEmoji(emoji.value ?: "")) Color(0xffD63C31) else MaterialTheme.colors.onPrimary, + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ) + TextField( + value = state.value, + onValueChange = { newValue -> + if (newValue.text == emoji.value) { + state.value = newValue + return@TextField + } + val newValueClamped = newValue.text.replace(emoji.value ?: "", "") + val isEmoji = isShortEmoji(newValueClamped) + emoji.value = if (isEmoji) newValueClamped else null + state.value = if (isEmoji) newValue else TextFieldValue() + }, + singleLine = true, + modifier = Modifier + .padding(4.dp) + .size(width = TextFieldDefaults.MinHeight.value.sp.toDp(), height = TextFieldDefaults.MinHeight), + textStyle = LocalTextStyle.current.copy(fontFamily = EmojiFont, textAlign = TextAlign.Center), + placeholder = { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } + }, + colors = colors, + ) +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cde173f24c..cfcec12593 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.3-beta.0 +android.version_code=267 -desktop.version_name=6.2.1 -desktop.version_code=83 +desktop.version_name=6.3-beta.0 +desktop.version_code=86 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 diff --git a/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md b/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md index 502a42c559..339fab4d16 100644 --- a/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md +++ b/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md @@ -72,7 +72,7 @@ This is a small but important change - you can now see who reacted to your messa ### Improving notifications in iOS app -iOS notifications in a decentralized network is a complex problems. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough. +iOS notifications in a decentralized network is a complex problem. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough. We solved several problems of notification delivery in this release: - messaging servers no longer lose notifications while notification servers are restarted. diff --git a/cabal.project b/cabal.project index c8ab5b4c28..ee3fab10bd 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f5cef2556b05cf384db284644c112f822407b361 + tag: 184a95cd2a2806fb3d318508ca1f6528201a8114 source-repository-package type: git diff --git a/package.yaml b/package.yaml deleted file mode 100644 index 2eec40d9d1..0000000000 --- a/package.yaml +++ /dev/null @@ -1,187 +0,0 @@ -name: simplex-chat -version: 6.2.0.7 -#synopsis: -#description: -homepage: https://github.com/simplex-chat/simplex-chat#readme -license: AGPL-3 -author: simplex.chat -maintainer: chat@simplex.chat -copyright: 2020-22 simplex.chat -category: Web, System, Services, Cryptography -extra-source-files: - - README.md - - PRIVACY.md - - cabal.project - -dependencies: - - aeson == 2.2.* - - ansi-terminal >= 0.10 && < 0.12 - - async == 2.2.* - - attoparsec == 0.14.* - - base >= 4.7 && < 5 - - base64-bytestring >= 1.0 && < 1.3 - - composition == 1.0.* - - constraints >= 0.12 && < 0.14 - - containers == 0.6.* - - crypton == 0.34.* - - data-default >= 0.7 && < 0.8 - - directory == 1.3.* - - direct-sqlcipher == 2.3.* - - email-validate == 2.3.* - - exceptions == 0.10.* - - filepath == 1.4.* - - file-embed == 0.0.15.* - - http-types == 0.12.* - - http2 >= 4.2.2 && < 4.3 - - memory == 0.18.* - - mtl >= 2.3.1 && < 3.0 - - network >= 3.1.2.7 && < 3.2 - - network-transport == 0.5.6 - - optparse-applicative >= 0.15 && < 0.17 - - random >= 1.1 && < 1.3 - - record-hasfield == 1.0.* - - scientific ==0.3.7.* - - simple-logger == 0.1.* - - simplexmq >= 5.0 - - socks == 0.6.* - - sqlcipher-simple == 0.4.* - - stm == 2.5.* - - terminal == 0.2.* - - time == 1.12.* - - tls >= 1.9.0 && < 1.10 - - unliftio == 0.2.* - - unliftio-core == 0.2.* - - uuid == 1.3.* - - zip == 2.0.* - -flags: - swift: - description: Enable swift JSON format - manual: True - default: False - -when: - - condition: flag(swift) - cpp-options: - - -DswiftJSON - - condition: impl(ghc >= 9.6.2) - dependencies: - - bytestring == 0.11.* - - process == 1.6.* - - template-haskell == 2.20.* - - text >= 2.0.1 && < 2.2 - - condition: impl(ghc < 9.6.2) - dependencies: - - bytestring == 0.10.* - - process >= 1.6 && < 1.6.18 - - template-haskell == 2.16.* - - text >= 1.2.4.0 && < 1.3 - -library: - source-dirs: src - -executables: - simplex-chat: - source-dirs: apps/simplex-chat - main: Main.hs - dependencies: - - simplex-chat - - network == 3.1.* - - websockets == 0.12.* - ghc-options: - - -threaded - - simplex-bot: - source-dirs: apps/simplex-bot - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-bot-advanced: - source-dirs: apps/simplex-bot-advanced - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-broadcast-bot: - source-dirs: - - apps/simplex-broadcast-bot - - apps/simplex-broadcast-bot/src - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-directory-service: - source-dirs: - - apps/simplex-directory-service - - apps/simplex-directory-service/src - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - -tests: - simplex-chat-test: - source-dirs: - - tests - - apps/simplex-broadcast-bot/src - - apps/simplex-directory-service/src - main: Test.hs - when: - - condition: impl(ghc >= 9.6.2) - dependencies: - - hspec == 2.11.* - - condition: impl(ghc < 9.6.2) - dependencies: - - hspec == 2.7.* - dependencies: - - QuickCheck == 2.14.* - - simplex-chat - - async == 2.2.* - - deepseq == 1.4.* - - generic-random == 1.5.* - - network == 3.1.* - - silently == 1.2.* - - stm == 2.5.* - ghc-options: - - -threaded - -ghc-options: - # - -haddock - - -O2 - - -Weverything - - -Wno-missing-exported-signatures - - -Wno-missing-import-lists - - -Wno-missed-specialisations - - -Wno-all-missed-specialisations - - -Wno-unsafe - - -Wno-safe - - -Wno-missing-local-signatures - - -Wno-missing-kind-signatures - - -Wno-missing-deriving-strategies - - -Wno-monomorphism-restriction - - -Wno-prepositive-qualified-module - - -Wno-unused-packages - - -Wno-implicit-prelude - - -Wno-missing-safe-haskell-mode - - -Wno-missing-export-lists - - -Wno-partial-fields - - -Wcompat - - -Werror=incomplete-record-updates - - -Werror=incomplete-patterns - - -Werror=missing-methods - - -Werror=incomplete-uni-patterns - - -Werror=tabs - - -Wredundant-constraints - - -Wincomplete-record-updates - - -Wunused-type-patterns - -default-extensions: - - StrictData diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 9b915532f7..5ed491f6ab 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,44 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-3:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
+ + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-2:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7887987f12..b68a60ec9b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f5cef2556b05cf384db284644c112f822407b361" = "14w0fji1fdr63w4pjkhlrdiz93p2axghzkb1ykvnrw111by87g8l"; + "https://github.com/simplex-chat/simplexmq.git"."184a95cd2a2806fb3d318508ca1f6528201a8114" = "0zlxi2dfv86sph9ndnazlvqgy0kq5rd1j6pjh41pswhaz8diwcag"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4e917e885b..c471d342cb 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.0.7 +version: 6.3.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -239,7 +239,7 @@ library , record-hasfield ==1.0.* , scientific ==0.3.7.* , simple-logger ==0.1.* - , simplexmq >=5.0 + , simplexmq >=6.3 , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* @@ -276,61 +276,10 @@ executable simplex-bot StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* - , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* + base >=4.7 && <5 , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* - , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON - if impl(ghc >= 9.6.2) - build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 - if impl(ghc < 9.6.2) - build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 executable simplex-bot-advanced main-is: Main.hs @@ -342,61 +291,19 @@ executable simplex-bot-advanced StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + async ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot main-is: Main.hs @@ -411,61 +318,20 @@ executable simplex-broadcast-bot Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + async ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-chat main-is: Main.hs @@ -479,61 +345,22 @@ executable simplex-chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* , websockets ==0.12.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-directory-service main-is: Main.hs @@ -551,60 +378,26 @@ executable simplex-directory-service Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* + async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* - , constraints >=0.12 && <0.14 , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* , text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test @@ -659,60 +452,36 @@ test-suite simplex-chat-test , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* - , constraints >=0.12 && <0.14 , containers ==0.6.* , crypton ==0.34.* - , data-default ==0.7.* , deepseq ==1.4.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* , filepath ==1.4.* , generic-random ==1.5.* , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* + , simplexmq >=6.3 , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* - , tls >=1.9.0 && <1.10 , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* + , hspec ==2.11.* , process ==1.6.* - , template-haskell ==2.20.* , text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: bytestring ==0.10.* + , hspec ==2.7.* , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 - if impl(ghc >= 9.6.2) - build-depends: - hspec ==2.11.* - if impl(ghc < 9.6.2) - build-depends: - hspec ==2.7.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7a23792a6a..8d5e1fea8d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -36,7 +36,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types -import Simplex.Chat.Util (shuffle) +import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfc4fe2fa0..8c7a0bc552 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, 3, 0, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 2, 0, 7] +minRemoteHostVersion = AppVersion [6, 3, 0, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version