Merge branch 'master' into av/ios-open-file

This commit is contained in:
Evgeny Poberezkin 2024-12-27 16:10:55 +00:00
commit 72722498f1
No known key found for this signature in database
GPG key ID: 494BDDD9A28B577D
44 changed files with 1913 additions and 583 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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 = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a"; sourceTree = "<group>"; };
649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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;

View file

@ -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)

View file

@ -27,6 +27,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Allows to query app name and icon that can open specific file type -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
</queries>
<application
android:name="SimplexApp"
android:allowBackup="false"

View file

@ -470,53 +470,65 @@ class SimplexService: Service() {
)
}
private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert {
val ignoreOptimization = {
AlertManager.shared.hideAlert()
askAboutIgnoringBatteryOptimization()
private var showingIgnoreNotification = false
private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) {
// that's workaround for situation when the app receives onPause/onResume events multiple times
// (for example, after showing system alert for enabling notifications) which triggers showing that alert multiple times
if (showingIgnoreNotification) {
return
}
val disableNotifications = {
AlertManager.shared.hideAlert()
disableNotifications(mode, showOffAlert)
}
AlertDialog(
onDismissRequest = disableNotifications,
title = {
Row {
Icon(
painterResource(MR.images.ic_bolt),
contentDescription =
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
)
Text(
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications),
fontWeight = FontWeight.Bold
)
}
},
text = {
Column {
Text(
if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(MR.strings.turn_off_battery_optimization))
if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) {
Text(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization),
Modifier.padding(top = 8.dp)
showingIgnoreNotification = true
AlertManager.shared.showAlert {
val ignoreOptimization = {
AlertManager.shared.hideAlert()
showingIgnoreNotification = false
askAboutIgnoringBatteryOptimization()
}
val disableNotifications = {
AlertManager.shared.hideAlert()
showingIgnoreNotification = false
disableNotifications(mode, showOffAlert)
}
AlertDialog(
onDismissRequest = disableNotifications,
title = {
Row {
Icon(
painterResource(MR.images.ic_bolt),
contentDescription =
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
)
Text(
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications),
fontWeight = FontWeight.Bold
)
}
}
},
dismissButton = {
TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) }
},
confirmButton = {
TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) }
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
},
text = {
Column {
Text(
if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(MR.strings.turn_off_battery_optimization))
if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) {
Text(
annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization),
Modifier.padding(top = 8.dp)
)
}
}
},
dismissButton = {
TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) }
},
confirmButton = {
TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) }
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
}
}
private fun showBGServiceNoticeSystemRestricted(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert {

View file

@ -87,6 +87,9 @@ kotlin {
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0")
// Emojis
implementation("androidx.emoji2:emoji2-emojipicker:1.4.0")
implementation("com.jakewharton:process-phoenix:3.0.0")
val cameraXVersion = "1.3.4"

View file

@ -3,19 +3,30 @@ package chat.simplex.common.platform
import android.Manifest
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
import java.net.URI
import kotlin.math.min
data class OpenDefaultApp(
val name: String,
val icon: ImageBitmap,
val isSystemChooser: Boolean
)
actual fun ClipboardManager.shareText(text: String) {
var text = text
for (i in 10 downTo 1) {
@ -37,7 +48,7 @@ actual fun ClipboardManager.shareText(text: String) {
}
}
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean, useChooser: Boolean = true) {
val uri = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
@ -67,9 +78,35 @@ fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
type = mimeType
}
}
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
if (useChooser) {
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
} else {
sendIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(sendIntent)
}
}
fun queryDefaultAppForExtension(ext: String, encryptedFileUri: URI): OpenDefaultApp? {
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null
val openIntent = Intent(Intent.ACTION_VIEW)
openIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
openIntent.setDataAndType(encryptedFileUri.toUri(), mimeType)
val pm = androidAppContext.packageManager
//// This method returns the list of apps but no priority, nor default flag
// val resInfoList: List<ResolveInfo> = 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) {

View file

@ -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<Boolean>,
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
}
)
}
}

View file

@ -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<String>, showError: State<Boolean>, emoji: MutableState<String?>) {
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)
}
}
}
)
}
}

View file

@ -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 {

View file

@ -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<GroupMember>()
val groupMembersIndexes = mutableStateMapOf<Long, Int>()
// Chat Tags
val userTags = mutableStateOf(emptyList<ChatTag>())
val activeChatTagFilter = mutableStateOf<ActiveFilter?>(null)
val presetTags = mutableStateMapOf<PresetTagKind, Int>()
val unreadTags = mutableStateMapOf<Long, Int>()
// 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<Boolean>()
@ -196,6 +203,116 @@ object ChatModel {
}
}
fun updateChatTags(rhId: Long?) {
val newPresetTags = mutableMapOf<PresetTagKind, Int>()
val newUnreadTags = mutableMapOf<Long, Int>()
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<Long> = 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<Long>?, newTags: List<Long>) {
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<Long>) {
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<Long>?
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<Long>,
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<Long>
): 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<ChatItemTTL?> {
}
}
@Serializable
data class ChatTag(
val chatTagId: Long,
val chatTagText: String,
val chatTagEmoji: String?
)
@Serializable
class ChatItemInfo(
val itemVersions: List<ChatItemVersion>,

View file

@ -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<ChatTag>?{
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<Chat, NavigationInfo>? {
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<ChatTag>? {
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<Long>): Pair<List<ChatTag>, List<Long>>? {
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<Long>) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds))
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
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<ComposedMessage>): CC()
class ApiCreateChatTag(val tag: ChatTagData): CC()
class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List<Long>): CC()
class ApiDeleteChatTag(val tagId: Long): CC()
class ApiUpdateChatTag(val tagId: Long, val tagData: ChatTagData): CC()
class ApiReorderChatTags(val tagIds: List<Long>): CC()
class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List<ComposedMessage>): 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<Long>, 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<Chat>): 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<ChatTag>): 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<ChatTag>, val chatTags: List<Long>): 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)}" )

View file

@ -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)
)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
)
}
}

View file

@ -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)
)
}
}

View file

@ -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<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
)
@Composable
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->

View file

@ -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,

View file

@ -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<Boolean>) {
@ -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<Boolean>
) {
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<Boolean>) {
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

View file

@ -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<Boolean>) {
ModalManager.start.closeModals()
ModalManager.end.closeModals()
@ -187,6 +197,12 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
}
}
if (appPlatform.isAndroid) {
val wasAllowedToSetupNotifications = rememberSaveable { mutableStateOf(false) }
val canEnableNotifications = remember { derivedStateOf { chatModel.chatRunning.value == true } }
if (wasAllowedToSetupNotifications.value || canEnableNotifications.value) {
SetNotificationsModeAdditions()
LaunchedEffect(Unit) { wasAllowedToSetupNotifications.value = true }
}
tryOrShowError("UserPicker", error = {}) {
UserPicker(
chatModel = chatModel,
@ -557,17 +573,24 @@ private fun BoxScope.unreadBadge(text: String? = "") {
@Composable
private fun ToggleFilterEnabledButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
val showUnread = remember { chatModel.activeChatTagFilter }.value == ActiveFilter.Unread
IconButton(onClick = {
if (showUnread) {
chatModel.activeChatTagFilter.value = null
} else {
chatModel.activeChatTagFilter.value = ActiveFilter.Unread
}
}) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
tint = if (showUnread) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.background(color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(sp16)
)
@ -731,6 +754,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, 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<TextFieldValue>, 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<String?>(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<TextFieldValue>, 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<TextFieldValue>, 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<TextFieldValue>, listStat
}
}
}
LaunchedEffect(activeFilter.value) {
searchText.value = TextFieldValue("")
}
}
@Composable
private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
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<Boolean>
) {
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<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchText: String,
chats: List<Chat>
chats: List<Chat>,
activeFilter: ActiveFilter? = null,
): List<Chat> {
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<ImageResource, StringResource> =
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()) } }

View file

@ -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)

View file

@ -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<Boolean> = 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<Long>) {
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<Boolean?>(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<Boolean>, saving: MutableState<Boolean>) {
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<Boolean>) {
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<String>, showError: State<Boolean>, emoji: MutableState<String?>)
@Composable
fun TagListNameTextField(name: MutableState<String>, showError: State<Boolean>) {
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<Long> = 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<Boolean>) {
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<Boolean>) {
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
)
}
}
)
}

View file

@ -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<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
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<Int?>(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) }
}

View file

@ -188,6 +188,9 @@
<string name="error_updating_user_privacy">Error updating user privacy</string>
<string name="possible_slow_function_title">Slow function</string>
<string name="possible_slow_function_desc">Execution of function takes too long time: %1$d seconds: %2$s</string>
<string name="error_updating_chat_tags">Error updating chat list</string>
<string name="error_creating_chat_tags">Error creating chat list</string>
<string name="error_loading_chat_tags">Error loading chat lists</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Instant notifications</string>
@ -361,6 +364,7 @@
<string name="revoke_file__confirm">Revoke</string>
<string name="forward_chat_item">Forward</string>
<string name="download_file">Download</string>
<string name="list_menu">List</string>
<string name="message_forwarded_title">Message forwarded</string>
<string name="message_forwarded_desc">No direct connection yet, message is forwarded by admin.</string>
@ -390,6 +394,10 @@
<string name="you_have_no_chats">You have no chats</string>
<string name="loading_chats">Loading chats…</string>
<string name="no_filtered_chats">No filtered chats</string>
<string name="no_chats_in_list">No chats in list %s.</string>
<string name="no_unread_chats">No unread chats</string>
<string name="no_chats">No chats</string>
<string name="no_chats_found">No chats found</string>
<string name="contact_tap_to_connect">Tap to Connect</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
@ -409,6 +417,12 @@
<string name="forward_files_missing_desc">%1$d file(s) were deleted.</string>
<string name="forward_files_not_accepted_receive_files">Download</string>
<string name="forward_files_messages_deleted_after_selection_title">%1$s messages not forwarded</string>
<string name="chat_list_favorites">Favorites</string>
<string name="chat_list_contacts">Contacts</string>
<string name="chat_list_groups">Groups</string>
<string name="chat_list_businesses">Businesses</string>
<string name="chat_list_all">All</string>
<string name="chat_list_add_list">Add list</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
@ -482,6 +496,7 @@
<string name="loading_remote_file_desc">Please, wait while the file is being loaded from the linked mobile</string>
<string name="file_error">File error</string>
<string name="temporary_file_error">Temporary file error</string>
<string name="open_with_app">Open with %s</string>
<!-- Voice messages -->
<string name="voice_message">Voice message</string>
@ -626,6 +641,16 @@
<string name="favorite_chat">Favorite</string>
<string name="unfavorite_chat">Unfavorite</string>
<!-- Tags - ChatListNavLinkView.kt -->
<string name="create_list">Create list</string>
<string name="add_to_list">Add to list</string>
<string name="save_list">Save list</string>
<string name="list_name_field_placeholder">List name...</string>
<string name="duplicated_list_error">List name and emoji should be different for all lists.</string>
<string name="delete_chat_list_menu_action">Delete</string>
<string name="delete_chat_list_question">Delete list?</string>
<string name="delete_chat_list_warning">All chats will be removed from the list %s, and the list deleted</string>
<string name="edit_chat_list_menu_action">Edit</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_a_contact">You invited a contact</string>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M170-368v-75h620v75H170Zm0-150v-75h620v75H170Z"/></svg>

After

Width:  |  Height:  |  Size: 171 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M54.5-279q0-33 16.75-60.25T116.5-381q61-30 123.5-45.25t126-15.25q63.5 0 125.75 15.25T615-381q28.5 14.5 45.25 41.75T677-279v31q0 31-22 53t-53 22H129.5q-31 0-53-22t-22-53v-31Zm677 106q10-17 15.25-36t5.25-39v-35q0-43.5-22.5-83.75T663-434.5q48.5 6 91.25 19.75t80.25 34.25Q869-362 887.25-338t18.25 52v38q0 31-22 53t-53 22h-99ZM366-479q-64 0-109-45t-45-109q0-64 45-109t109-45q64 0 109 45t45 109q0 64-45 109t-109 45Zm382-154.5q0 63.5-45 108.75T594-479.5q-9.5 0-25.25-2.25T543-487q26.5-30.5 40.75-68T598-633.5q0-40.5-14.25-78.25T543-780q12.5-4.5 25.5-5.75T594-787q64 0 109 45t45 108.5Z"/></svg>

After

Width:  |  Height:  |  Size: 683 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="m859-406.5-304 305Q545.93-93 535.21-89q-10.71 4-21.71 4t-21.25-4.25Q482-93.5 473.5-101.5L102.5-474q-8-7.5-12.75-17.97Q85-502.44 85-514v-303.5q0-23.72 16.89-40.61T142.5-875h305q11.41 0 22.11 4.4 10.71 4.39 18.89 12.6L859-488q8.91 8.92 13.21 19.52 4.29 10.6 4.29 21.21 0 11.27-4.5 22.27t-13 18.5Zm-343 266L820-446 447.49-817.5H142.5v301.77L516-140.5ZM246.75-664q20.5 0 35.63-15.04 15.12-15.03 15.12-35.37 0-20.34-15.06-35.47Q267.38-765 247-765q-20.75 0-35.62 15.04-14.88 15.03-14.88 35.37 0 20.34 14.88 35.46Q226.25-664 246.75-664ZM481.5-479Z"/></svg>

After

Width:  |  Height:  |  Size: 646 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="m859-406.5-304 305Q545.93-93 535.21-89q-10.71 4-21.71 4t-21.25-4.25Q482-93.5 473.5-101.5L102.5-474q-8-7.5-12.75-17.97Q85-502.44 85-514v-303.5q0-23.72 16.89-40.61T142.5-875h305q11.41 0 22.11 4.4 10.71 4.39 18.89 12.6L859-488q8.91 8.92 13.21 19.52 4.29 10.6 4.29 21.21 0 11.27-4.5 22.27t-13 18.5ZM246.75-664q20.5 0 35.63-15.04 15.12-15.03 15.12-35.37 0-20.34-15.06-35.47Q267.38-765 247-765q-20.75 0-35.62 15.04-14.88 15.03-14.88 35.37 0 20.34 14.88 35.46Q226.25-664 246.75-664Z"/></svg>

After

Width:  |  Height:  |  Size: 581 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M480-479q-64.5 0-109.75-45.25T325-634q0-64.5 45.25-109.75T480-789q64.5 0 109.75 45.25T635-634q0 64.5-45.25 109.75T480-479ZM169-248v-31.03q0-32.97 16.75-60.22t45.27-41.76Q292-411 354.25-426.25 416.5-441.5 480-441.5t125.75 15.25Q668-411 728.98-381.01q28.52 14.51 45.27 41.76Q791-312 791-279.03V-248q0 30.94-22.03 52.97Q746.94-173 716-173H244q-30.94 0-52.97-22.03Q169-217.06 169-248Z"/></svg>

After

Width:  |  Height:  |  Size: 486 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M165-130q-30.94 0-52.97-22.03Q90-174.06 90-205v-440q0-30.94 22.03-52.97Q134.06-720 165-720h161v-75.04Q326-826 348.03-848T401-870h158q30.94 0 52.97 22.03Q634-825.94 634-795v75h161q30.94 0 52.97 22.03Q870-675.94 870-645v440q0 30.94-22.03 52.97Q825.94-130 795-130H165Zm236-590h158v-75H401v75Z"/></svg>

After

Width:  |  Height:  |  Size: 395 B

View file

@ -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<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
) {
}

View file

@ -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

View file

@ -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<String>, showError: State<Boolean>, emoji: MutableState<String?>) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SingleEmojiInput(emoji)
TagListNameTextField(name, showError = showError)
}
}
@Composable
private fun SingleEmojiInput(
emoji: MutableState<String?>
) {
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,
)
}

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -38,6 +38,44 @@
</description>
<releases>
<release version="6.2.3" date="2024-12-26">
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
<description>
<p>New in v6.2.1-3:</p>
<ul>
<li>important fixes</li>
<li>offer to "fix" encryption when calling or making direct connection with member.</li>
<li>broken layout.</li>
<li>option to enable debug logs (disabled by default).</li>
<li>show who reacted in direct chats.</li>
</ul>
<p>New in v6.2:</p>
<ul>
<li>SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app to improve metadata privacy.</li>
<li>Business chats your customers privacy.</li>
<li>Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.</li>
</ul>
</description>
</release>
<release version="6.2.2" date="2024-12-25">
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
<description>
<p>New in v6.2.1-2:</p>
<ul>
<li>important fixes</li>
<li>offer to "fix" encryption when calling or making direct connection with member.</li>
<li>broken layout.</li>
<li>option to enable debug logs (disabled by default).</li>
<li>show who reacted in direct chats.</li>
</ul>
<p>New in v6.2:</p>
<ul>
<li>SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app to improve metadata privacy.</li>
<li>Business chats your customers privacy.</li>
<li>Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.</li>
</ul>
</description>
</release>
<release version="6.2.1" date="2024-12-12">
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
<description>

View file

@ -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";

View file

@ -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.*

View file

@ -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)

View file

@ -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