diff --git a/README.md b/README.md index a515a25df6..601ab5a161 100644 --- a/README.md +++ b/README.md @@ -220,12 +220,14 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: -[Jul 29, 2025 SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md) +[Jul 22, 2026. SimpleX Public Names — a Name Nobody Can Take From You](./blog/20260722-simplex-public-names.md) + +[Apr 30, 2026. SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech](./blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md) + +[Jul 29, 2025. SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md) [Jul 3, 2025 SimpleX network: new experience of connecting with people — available in SimpleX Chat v6.4-beta.4](./blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.md) -[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md) - [Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) [Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) @@ -242,10 +244,6 @@ Recent and important updates: [Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). -[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md). - -[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). - [All updates](./blog) ## :zap: Quick installation of a terminal app diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 99de13451f..2213b34586 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -216,6 +216,9 @@ struct ChatItemInfoView: View { } } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } } } @@ -545,6 +548,9 @@ struct ChatItemInfoView: View { shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)] } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + shareText += [String.localizedStringWithFormat(NSLocalizedString("File servers: %@", comment: "copied message info"), servers.map(serverHostname).joined(separator: ", "))] + } if let qi = ci.quotedItem { shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] let t = qi.text diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index 92580c1f4f..168d865b4b 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -10653,8 +10653,8 @@ More improvements are coming soon! Owner 擁有者 - - Owners< & contributors + + Owners & contributors 擁有者 diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2098113bef..0495961d59 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -563,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -735,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.9-J30uILApgSuBRyrol6CHua.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.10-Fr7Tzkw2TAVKpbtViCLw34.a */, ); path = Libraries; sourceTree = ""; @@ -2081,7 +2081,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2131,7 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2173,7 +2173,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2193,7 +2193,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2218,7 +2218,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2255,7 +2255,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2292,7 +2292,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2343,7 +2343,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2397,7 +2397,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2431,7 +2431,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 342; + CURRENT_PROJECT_VERSION = 343; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 91b2a85fa8..d02bff75f8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -6033,6 +6033,7 @@ public struct ChatItemInfo: Decodable, Hashable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? } public struct ChatItemVersion: Decodable, Hashable { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index bacdfe70af..7415b6015d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -1,6 +1,5 @@ package chat.simplex.app -import android.content.Context import android.content.Intent import android.net.Uri import android.os.* @@ -8,7 +7,6 @@ import android.view.View import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.ui.platform.ClipboardManager import androidx.fragment.app.FragmentActivity import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent @@ -21,7 +19,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.* import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { @@ -73,17 +70,6 @@ class MainActivity: FragmentActivity() { override fun onResume() { super.onResume() AppLock.recheckAuthState() - withApi { - delay(1000) - if (!isAppOnForeground) return@withApi - /** - * When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies - * access to clipboard because the app considered in background. - * This will ensure that the app will get the event on resume - * */ - val service = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - chatModel.clipboardHasText.value = service.hasPrimaryClip() - } } override fun onPause() { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 5cf17d304d..c98f8f9f89 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.helpers -import android.content.ClipboardManager -import android.content.Context import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -14,7 +12,6 @@ import android.text.SpannedString import android.text.style.* import android.util.Base64 import android.view.WindowManager -import androidx.compose.runtime.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* @@ -58,19 +55,6 @@ fun keepScreenOn(on: Boolean) { } } -@Composable -actual fun SetupClipboardListener() { - DisposableEffect(Unit) { - val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val listener = { chatModel.clipboardHasText.value = service.hasPrimaryClip() } - chatModel.clipboardHasText.value = service.hasPrimaryClip() - service.addPrimaryClipChangedListener(listener) - onDispose { - service.removePrimaryClipChangedListener(listener) - } - } -} - actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 7542a0b8c6..cb91c386ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -163,7 +163,6 @@ fun MainScreen() { userPickerState.value = AnimatedViewState.VISIBLE } } - SetupClipboardListener() if (appPlatform.isAndroid) { AndroidWrapInCallLayout { AndroidScreen(userPickerState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a755b8bc6e..58c455840e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -210,7 +210,6 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } - val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty) @@ -5383,7 +5382,8 @@ data class ChatTag( class ChatItemInfo( val itemVersions: List, val memberDeliveryStatuses: List?, - val forwardedFromChatItem: AChatItem? + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() ) @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 0679edd4f9..64c6160665 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.MemberProfileImage import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString @@ -290,6 +291,16 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } } } + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView(stringResource(MR.strings.info_row_file_servers)) { + ciInfo.fileXftpServers.forEach { server -> + SectionItemView { + Text(serverHostname(server), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth()) + } + } + } + } } @Composable @@ -581,6 +592,9 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem shareText.add(String.format(generalGetString(MR.strings.share_text_file_status), jsonShort.encodeToString(ci.file.fileStatus))) } } + if (ci.file != null && chatItemInfo.fileXftpServers.isNotEmpty()) { + shareText.add(String.format(generalGetString(MR.strings.share_text_file_servers), chatItemInfo.fileXftpServers.joinToString(", ") { serverHostname(it) })) + } val qi = ci.quotedItem if (qi != null) { shareText.add("") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e762c5610d..e078a14d54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1909,10 +1909,13 @@ fun BoxScope.ChatItemsList( reveal: (Boolean) -> Unit ) { val itemScope = rememberCoroutineScope() + val viewConfiguration = LocalViewConfiguration.current CompositionLocalProvider( // Makes horizontal and vertical scrolling to coexist nicely. - // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view - LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view. + // remember: pointerInput handlers observe ViewConfiguration and reset on any change, so a new + // instance per recomposition kills in-flight presses/hover whenever a message is inserted + LocalViewConfiguration provides remember(viewConfiguration) { viewConfiguration.bigTouchSlop() } ) { val provider = { providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> @@ -1960,7 +1963,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = if (appPlatform.isDesktop || !chatInfo.sendMsgEnabled) Modifier else SwipeToDismissModifier( + val swipeableModifier = if (appPlatform.isDesktop || !chatInfo.sendMsgEnabled || cItem.meta.itemDeleted != null) Modifier else SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 4c84965b9e..875bf6d263 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1308,13 +1308,6 @@ fun ComposeView( } } - LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { - if (!chat.chatInfo.sendMsgEnabled) { - clearCurrentDraft() - clearState() - } - } - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { @@ -1345,6 +1338,14 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + // Must be composed after KeyChangeEffect above (effects run in composition order), + // so that on chat switch the previous chat's draft is saved before it is cleared here. + LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { + if (!chat.chatInfo.sendMsgEnabled) { + clearCurrentDraft() + clearState() + } + } // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented LaunchedEffect(chat.chatInfo) { val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index f8dfba4c6c..8ca0add460 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -25,6 +25,7 @@ import chat.simplex.common.views.chat.chatViewScrollState import dev.icerock.moko.resources.StringResource import java.io.File import java.net.URI +import kotlin.math.roundToInt @Composable fun CIVideoView( @@ -38,12 +39,25 @@ fun CIVideoView( receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } + val preview = remember(image) { base64ToBitmap(image) } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) + .then( + if (!smallView) { + val w = if (preview.width * 0.97 <= preview.height) videoViewFullWidth(LocalWindowWidth()) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH + // Size the media box from the preview aspect ratio (as CIImageView does), else the unprepared player surface + // expands to PriorityLayout's max height and shows as a black strip; height tracks the clamped width (#7223). + Modifier.width(w).layout { measurable, constraints -> + val width = constraints.maxWidth.coerceAtMost(w.roundToPx().coerceAtLeast(0)) + val height = (width * (preview.height.toFloat() / preview.width.toFloat()).coerceAtMost(2.33f)).roundToInt().coerceAtMost(constraints.maxHeight) + val placeable = measurable.measure(Constraints.fixed(width, height)) + layout(width, height) { placeable.place(0, 0) } + } + } else Modifier + ) .desktopModifyBlurredState(!smallView, blurred, showMenu), contentAlignment = Alignment.TopEnd ) { - val preview = remember(image) { base64ToBitmap(image) } val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } val sizeMultiplier = if (smallView) 0.38f else 1f if (chatModel.connectedToRemote()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 9d952eb10f..7e66c15937 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -12,8 +12,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.AnnotatedString.Range @@ -394,12 +396,12 @@ fun MarkdownText ( }, onHover = { offset -> val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } - icon.value = - if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") || hasAnnotation("MODAL")) { - PointerIcon.Hand - } else { - PointerIcon.Text - } + val hand = hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") || hasAnnotation("MODAL") + icon.value = if (hand) PointerIcon.Hand else PointerIcon.Text + }, + onHoverExit = { + // reset icon.value too, or pointerHoverIcon re-displays a stale Hand on the next Enter + icon.value = PointerIcon.Text }, shouldConsumeEvent = { offset -> annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) @@ -433,34 +435,45 @@ fun ClickableText( onClick: (Int) -> Unit, onLongClick: (Int) -> Unit = {}, onHover: (Int) -> Unit = {}, + onHoverExit: () -> Unit = {}, shouldConsumeEvent: (Int) -> Boolean ) { val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick, onLongClick) { + // pointerInput keyed on these lambdas restarts on every recomposition (they are new + // instances each time), and a restart mid-gesture swallows the click/hover in flight; + // key on Unit and read the latest handlers via rememberUpdatedState instead + val currentOnClick = rememberUpdatedState(onClick) + val currentOnLongClick = rememberUpdatedState(onLongClick) + val currentOnHover = rememberUpdatedState(onHover) + val currentOnHoverExit = rememberUpdatedState(onHoverExit) + val currentShouldConsumeEvent = rememberUpdatedState(shouldConsumeEvent) + // to tell a moved pointer from text that shifted under a stationary pointer (see waitForUpOrCancellation) + val textCoordinates = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(Unit) { detectGesture(onLongPress = { pos -> layoutResult.value?.let { layoutResult -> - onLongClick(layoutResult.getOffsetForPosition(pos)) + currentOnLongClick.value(layoutResult.getOffsetForPosition(pos)) } }, onPress = { pos -> layoutResult.value?.let { layoutResult -> val res = tryAwaitRelease() if (res) { - onClick(layoutResult.getOffsetForPosition(pos)) + currentOnClick.value(layoutResult.getOffsetForPosition(pos)) } } - }, shouldConsumeEvent = { pos -> + }, positionInWindow = { textCoordinates.value?.positionInWindow() ?: Offset.Zero }, shouldConsumeEvent = { pos -> var consume = false layoutResult.value?.let { layoutResult -> - consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos)) + consume = currentShouldConsumeEvent.value(layoutResult.getOffsetForPosition(pos)) } consume } ) - }.pointerInput(onHover) { + }.pointerInput(Unit) { if (appPlatform.isDesktop) { - detectCursorMove { pos -> + detectCursorMove(onExit = { currentOnHoverExit.value() }) { pos -> layoutResult.value?.let { layoutResult -> - onHover(layoutResult.getOffsetForPosition(pos)) + currentOnHover.value(layoutResult.getOffsetForPosition(pos)) } } } @@ -468,7 +481,8 @@ fun ClickableText( BasicText( text = text, - modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator), + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator) + .onGloballyPositioned { textCoordinates.value = it }, style = style, softWrap = softWrap, overflow = overflow, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 5d94b9c2d6..1c2f34e88d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -367,7 +367,7 @@ private fun SMPSubscriptionsSection(subs: SMPServerSubs, summary: SMPServerSumma ) SubscriptionStatusIndicatorView(subs, summary.sessionsOrNew.hasSess) } - Column(Modifier.padding(PaddingValues()).fillMaxWidth()) { + SectionView { InfoRow( generalGetString(MR.strings.servers_info_subscriptions_connections_subscribed), numOrDash(subs.ssActive) @@ -540,20 +540,16 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, ) ) } - if (summary.stats != null || summary.sessions != null) { - SectionDividerSpaced() - } + } - if (summary.stats != null) { - XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt) - if (summary.sessions != null) { - SectionDividerSpaced() - } - } + if (summary.stats != null) { + SectionDividerSpaced() + XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt) + } - if (summary.sessions != null) { - ServerSessionsView(summary.sessions) - } + if (summary.sessions != null) { + SectionDividerSpaced() + ServerSessionsView(summary.sessions) } SectionBottomSpacer() @@ -572,27 +568,21 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r ) ) } - if (summary.stats != null || summary.subs != null || summary.sessions != null) { - SectionDividerSpaced() - } + } - if (summary.stats != null) { - SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt) - if (summary.subs != null || summary.sessions != null) { - SectionDividerSpaced() - } - } + if (summary.stats != null) { + SectionDividerSpaced() + SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt) + } - if (summary.subs != null) { - SMPSubscriptionsSection(subs = summary.subs, summary = summary, rh = rh) - if (summary.sessions != null) { - SectionDividerSpaced() - } - } + if (summary.subs != null) { + SectionDividerSpaced() + SMPSubscriptionsSection(subs = summary.subs, summary = summary, rh = rh) + } - if (summary.sessions != null) { - ServerSessionsView(summary.sessions) - } + if (summary.sessions != null) { + SectionDividerSpaced() + ServerSessionsView(summary.sessions) } SectionBottomSpacer() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt index 9252e8b032..d55f894491 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt @@ -46,6 +46,7 @@ private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { } suspend fun PointerInputScope.detectGesture( onLongPress: ((Offset) -> Unit)? = null, onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, + positionInWindow: (() -> Offset)? = null, shouldConsumeEvent: (Offset) -> Boolean ) = coroutineScope { val pressScope = PressGestureScopeImpl(this@detectGesture) @@ -57,8 +58,14 @@ suspend fun PointerInputScope.detectGesture( val shouldConsume = shouldConsumeEvent(down.position) if (shouldConsume) down.consumeDownChange() - pressScope.reset() + val downPositionInWindow = positionInWindow?.let { it() + down.position } + // reset() suspends until the previous gesture's press handler released the mutex, + // and release/cancel join resetJob, so flag writes can't be reordered across gestures + // (a fast second click would otherwise drop the first click's onClick). + // Mirrors detectTapGestures in Compose 1.8.2. + val resetJob = launch { pressScope.reset() } if (onPress !== NoPressGesture) launch { + resetJob.join() pressScope.onPress(down.position) } val longPressTimeout = onLongPress?.let { @@ -67,37 +74,45 @@ suspend fun PointerInputScope.detectGesture( try { val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) { - waitForUpOrCancellation() + waitForUpOrCancellation(downPositionInWindow, positionInWindow) } if (upOrCancel == null) { - pressScope.cancel() + launch { resetJob.join(); pressScope.cancel() } } else { if (shouldConsume) upOrCancel.consumeDownChange() - pressScope.release() + launch { resetJob.join(); pressScope.release() } } } catch (_: PointerEventTimeoutCancellationException) { if (onLongPress != null) { onLongPress(down.position) if (shouldConsume) consumeUntilUp() - pressScope.cancel() + launch { resetJob.join(); pressScope.cancel() } } else { if (shouldConsume) consumeUntilUp() - pressScope.release() + launch { resetJob.join(); pressScope.release() } } } } } } -suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) = coroutineScope { - forEachGesture { - awaitPointerEventScope { +suspend fun PointerInputScope.detectCursorMove(onExit: () -> Unit = {}, onMove: (Offset) -> Unit = {},) { + // One scope for all events: re-entering awaitPointerEventScope per event loses events. + // Enter/Release update hover when the pointer is stationary (content moved under it, or a click completed). + awaitPointerEventScope { + while (true) { val event = awaitPointerEvent() - if (event.type == PointerEventType.Move) { - onMove(event.changes[0].position) + if (event.type == PointerEventType.Move || event.type == PointerEventType.Enter || event.type == PointerEventType.Release) { + val pos = event.changes[0].position + // ignore events while a button is down or outside bounds: a pressed node receives them even after the pointer leaves it + if (event.changes.none { it.pressed } && pos.x >= 0 && pos.y >= 0 && pos.x < size.width && pos.y < size.height) { + onMove(pos) + } + } else if (event.type == PointerEventType.Exit) { + onExit() } } } @@ -130,7 +145,16 @@ internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass( return event.changes[0] } -suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? { +suspend fun AwaitPointerEventScope.waitForUpOrCancellation( + downPositionInWindow: Offset? = null, + positionInWindow: (() -> Offset)? = null +): PointerInputChange? { + // out of bounds in local coordinates while stationary in window coordinates is the node + // moving under the pointer (chat list shifted after a sent message), not the pointer + // leaving the node — such a press stays valid + fun stationaryInWindow(change: PointerInputChange): Boolean = + downPositionInWindow != null && positionInWindow != null && + (positionInWindow() + change.position - downPositionInWindow).getDistance() <= viewConfiguration.touchSlop while (true) { val event = awaitPointerEvent(PointerEventPass.Main) if (event.changes.all { it.changedToUp() }) { @@ -138,7 +162,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange } if (event.changes.any { - it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding) + it.consumed.downChange || (it.isOutOfBounds(size, extendedTouchPadding) && !stationaryInWindow(it)) } ) { return null @@ -159,16 +183,18 @@ private class PressGestureScopeImpl( fun cancel() { isCanceled = true - mutex.unlock() + if (mutex.isLocked) mutex.unlock() } fun release() { isReleased = true - mutex.unlock() + if (mutex.isLocked) mutex.unlock() } - fun reset() { - mutex.tryLock() + // suspends until the previous gesture's tryAwaitRelease finished (or was never started): + // the mutex is the serialization token between consecutive gestures + suspend fun reset() { + mutex.lock() isReleased = false isCanceled = false } @@ -176,6 +202,7 @@ private class PressGestureScopeImpl( override suspend fun tryAwaitRelease(): Boolean { if (!isReleased && !isCanceled) { mutex.lock() + mutex.unlock() } return isReleased && !isCanceled } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index eee97eeca9..70f4a1759b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -110,9 +110,6 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr } } -@Composable -expect fun SetupClipboardListener() - // maximum image file size to be auto-accepted // Spec: spec/services/files.md#MAX_IMAGE_SIZE const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index fd6b27482d..87784f009e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -196,7 +196,6 @@ private fun MutableState.ChatStopInProgressView() { private fun MutableState.ChatStopFailedView(reason: String) { SectionView(stringResource(MR.strings.error_stopping_chat)) { Text(reason) - SectionDividerSpaced() SettingsActionItemWithContent( icon = painterResource(MR.images.ic_report_filled), text = stringResource(MR.strings.auth_stop_chat), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 86be99d1bf..2d2de2881f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -147,8 +147,11 @@ private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList) { AppBarTitle(stringResource(MR.strings.verify_connection)) SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - CtrlDeviceNameText(session, rc) - CtrlDeviceVersionText(session) + Column { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } } SectionDividerSpaced() @@ -308,8 +316,11 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) { AppBarTitle(stringResource(MR.strings.connected_to_desktop)) SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Text(rc.deviceViewName) - CtrlDeviceVersionText(session) + Column { + Text(rc.deviceViewName) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } } if (session.sessionCode != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index 1c9e602f1b..93c5eb7756 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -561,7 +561,6 @@ object AppearanceScope { color = if (chatModel.remoteHostId != null && themeUserDestination.value != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - SectionDividerSpaced() } val state: State = remember(appPrefs.currentTheme.get()) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 6989521c6b..617c268b8a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2025,6 +2025,7 @@ Record updated at Message status File status + File servers Sent at Created at Received at @@ -2035,6 +2036,7 @@ Record updated at: %s Message status: %s File status: %s + File servers: %s Sent at: %s Created at: %s Received at: %s diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 7341c6af23..79aaf5c727 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -14,10 +14,12 @@ import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.awt.FileDialog +import java.awt.event.ActionListener import java.io.File import javax.swing.JFileChooser import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileNameExtensionFilter +import javax.swing.plaf.basic.BasicFileChooserUI @Composable actual fun DefaultDialog( @@ -77,6 +79,13 @@ fun FrameWindowScope.FileDialogChooserMultiple( fileChooser.dialogTitle = title fileChooser.isMultiSelectionEnabled = allowMultiple && isLoad fileChooser.isAcceptAllFileFilterUsed = fileFilter == null + // Only install the glob bypass for the real file-save case (filename != null). When filename + // is null the dialog runs in DIRECTORIES_ONLY mode (e.g. "Save QR code as image"), where the + // Save button must approve the selected directory — the literal-filename handler would instead + // traverse into it (or no-op on an empty field), making directory selection impossible. + if (!isLoad && filename != null && desktopPlatform.isLinux()) { + installUnixSaveGlobBypass(fileChooser) + } if (fileFilter != null && fileFilterDescription != null) { fileChooser.addChoosableFileFilter(object: FileFilter() { override fun accept(file: File?): Boolean = fileFilter(file) @@ -120,6 +129,28 @@ fun FrameWindowScope.FileDialogChooserMultiple( } } +// Replace the Save button's action with a literal-filename handler. This bypasses JFileChooser's +// glob-on-save behaviour, which mis-handles '[' as a glob char on Unix (breaking filenames like +// '[1].pdf') and is not a feature of any native OS save dialog — macOS NSSavePanel and native +// Windows / Linux GTK / KDE save dialogs all treat the typed filename as a literal name. +private fun installUnixSaveGlobBypass(fc: JFileChooser) { + val ui = fc.ui as? BasicFileChooserUI ?: return + val original: ActionListener = ui.approveSelectionAction + val btn = ui.getDefaultButton(fc) ?: return + btn.removeActionListener(original) + btn.addActionListener { + val name = ui.fileName?.takeIf { it.isNotEmpty() } ?: return@addActionListener + val typed = File(name) + val target = if (typed.isAbsolute) typed else File(fc.currentDirectory, name) + if (target.isDirectory && fc.isTraversable(target)) { + fc.currentDirectory = target + } else { + fc.selectedFile = target + fc.approveSelection() + } + } +} + /* * Has graphic glitches on many Linux distributions, so use only on non-Linux systems. Also file filter doesn't work on Windows * */ diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 32b1d5cff0..3ccb915661 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -1,8 +1,6 @@ package chat.simplex.common.views.helpers -import androidx.compose.runtime.* import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -12,7 +10,6 @@ import chat.simplex.common.model.CIFile import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState -import kotlinx.coroutines.delay import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.File @@ -119,18 +116,6 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } -@Composable -actual fun SetupClipboardListener() { - val clipboard = LocalClipboardManager.current - chatModel.clipboardHasText.value = clipboard.hasText() - LaunchedEffect(Unit) { - while (true) { - delay(1000) - chatModel.clipboardHasText.value = clipboard.hasText() - } - } -} - actual fun getAppFileUri(fileName: String): URI { val rh = chatModel.currentRemoteHost.value return if (rh == null) { diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 7e80ae2a00..1eb8b7cbf7 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0-beta.4 -android.version_code=363 +android.version_name=7.0-beta.5 +android.version_code=364 android.bundle=false -desktop.version_name=7.0-beta.4 -desktop.version_code=152 +desktop.version_name=7.0-beta.5 +desktop.version_code=153 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index c9939ea3c0..34a3b502ee 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -105,8 +105,7 @@ fun MainScreen() When onboarding is complete: 1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). -2. Sets up clipboard listener. -3. Routes to `AndroidScreen` or `DesktopScreen` based on platform. +2. Routes to `AndroidScreen` or `DesktopScreen` based on platform. ### Overlay Layers (bottom of MainScreen) diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 09457c4dd3..229c30d18e 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -155,7 +155,6 @@ Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex | [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState` | 147 | Notification content preview level | | [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | | [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list | -| [`clipboardHasText`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L185) | `MutableState` | 185 | System clipboard has text content | | [`networkInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L186) | `MutableState` | 186 | Network type and online status | | [`conditions`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L188) | `MutableState` | 188 | Server operator terms/conditions | | [`updatingProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L190) | `MutableState` | 190 | Progress indicator for app updates | diff --git a/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md b/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md index 4a63cb87ca..51f0db7475 100644 --- a/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md +++ b/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md @@ -1,6 +1,6 @@ --- layout: layouts/article.html -title: "SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech" +title: "SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding — to Preserve Freedom of Speech" date: 2026-04-30 previewBody: blog_previews/20260430.html image: images/20260430-home.png @@ -58,7 +58,7 @@ You can *register your interest* to participate in crowdfunding here: https://si Join the channel for updates [here](https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg) — you must install v6.5 to join it — or you can join a [read-only group](https://smp12.simplex.im/g#gJzy7ETpuvltqARIB73TQUpJ11Lz4Xpl9xeH9qNoGCg) from the previous app versions. -_Disclaimer: SimpleX Chat is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ +_Disclaimer: SimpleX Chat, Inc. is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ [^release]: v6.5 release also improved how new users make the first connection, increased security of sending web links, and has many other improvements — see *What's new* in the app or full release notes. diff --git a/blog/20260722-simplex-public-names.md b/blog/20260722-simplex-public-names.md new file mode 100644 index 0000000000..1b9220da4a --- /dev/null +++ b/blog/20260722-simplex-public-names.md @@ -0,0 +1,67 @@ +--- +layout: layouts/article.html +title: "SimpleX Public Names — a Name Nobody Can Take From You" +date: 2026-07-22 +preview: "You can now give your channel or business a test SimpleX name — from v7-beta." +image: images/20260722-register-name.png +imageWide: true +permalink: "/blog/20260722-simplex-public-names.html" +--- + +# SimpleX Public Names — a Name Nobody Can Take From You + +**Published:** Jul 22, 2026 + +You can now give your channel or business a test SimpleX name that people can remember. Test names are available from v7-beta[^testing]. + +## Public names for channels and businesses — without user IDs + + + + +Before names, the only way to bring people to your channel or business on SimpleX Network was a link — but you cannot use a link in a podcast or a poster: nobody would remember it. + +Every place where you could get a name so far belongs to someone else: Telegram can revoke your username, a registrar can suspend your domain. + +So we designed SimpleX names for no one to own the registry — on the Ethereum blockchain. If you register `example.simplex`, people type `#example` to join your channel, or `@example.simplex` to message you. If a server operator deletes your link, you can point the name to a new one. Only you control the name with the key in your wallet[^ens]. + +And we did not add user identifiers to do it. Names are only for those who want to be found — channels, businesses, communities — and servers still cannot see who joined your channel or wrote to you. + +We plan to launch `.simplex` names later this year, and to provide the first names to [crowdfunding investors](#community-crowdfunding) as perks. + +## How to register a name + + + +To register a test name you need an Ethereum wallet, such as MetaMask, and your SimpleX address or channel link from the app. + +Setting up a name takes two steps. On the SimpleX Name Service [test webpage](https://testing-names.simplex.chat), search for the name you want — currently 6 characters or more — paste your address or channel link into the page, and complete the registration. + +In the app, you need to claim the name for your channel or contact address — it prevents connecting to your channel or address via any other name. Open your SimpleX address or channel page, tap **Get SimpleX name**, enter the name, and tap Save. + +See [this guide](../docs/guide/register-simplex-name.md) for more details. + +## How to connect via names + + + +Type the name into the search bar — `#example.testing` to join the channel, `@example.testing` to send direct messages. You can also send names in messages — they work as links. + +Connecting via a name is private. Unlike most applications accessing the chain through a centralized RPC service, SimpleX Chat app resolves names via two independent servers of SimpleX Network, so that no server can see both the name and the user's IP address. + +Read more about names in the [whitepaper](https://github.com/simplex-chat/simplex-chat/blob/master/docs/protocol/names-overview.md): their purpose, architecture, security model and planned future work. + +## Community Crowdfunding + +To ensure the long term success of SimpleX Network we established [SimpleX Network Consortium](https://simplexnetwork.org/consortium.html) — an agreement between a non-profit foundation created for protocol licensing and governance and SimpleX Chat, Inc. + +The commercial model for the network that we are building aims to make both our and other businesses on the network profitable. We recently [presented the technology design](https://www.youtube.com/watch?v=UhW8AuoRgxg) for this commercial model at Web3 Summit. + +The planned crowdfunding will fund this development. You can [register your interest](https://simplexchat.typeform.com/crowdfunding), and join the [SimpleX Crowdfunding News channel](https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg) for updates. + +_Disclaimer: SimpleX Chat, Inc. is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ + + +[^testing]: Test names are free to register; you only need to pay the blockchain fee. The `.testing` namespace is temporary — test names will stop working in the app one month after `.simplex` name sales launch. + +[^ens]: SimpleX Name Service (SNS) is a fork of Ethereum Name Service (ENS), but without its centralized dependencies. ENS depends on an off-chain indexer and a hosted metadata service. SNS is fully decentralized — names are indexed and hosted on the blockchain. See [Differences from ENS](https://github.com/simplex-chat/simplex-chat/blob/master/docs/protocol/names-overview.md#differences-from-ens). diff --git a/blog/README.md b/blog/README.md index 4544dc0f45..6122eb3c39 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,19 @@ # Blog +Jul 22, 2026 [SimpleX Public Names — a Name Nobody Can Take From You](./20260722-simplex-public-names.md) + +You can now give your channel or business a test SimpleX name that people can remember and nobody can take from you. Test names are free in v7-beta. + +--- + +Apr 30, 2026 [SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech](./20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md) + +Freedom of speech needs infrastructure that protects it by design - protocols, governance and funding. + +v6.5 release brings SimpleX Channels: a new model for online publishing built for participation privacy. + +--- + Jul 29, 2025 [SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md) What's new in v6.4.1: diff --git a/blog/images/20260722-connect-name.png b/blog/images/20260722-connect-name.png new file mode 100644 index 0000000000..7e835073ff Binary files /dev/null and b/blog/images/20260722-connect-name.png differ diff --git a/blog/images/20260722-phone-name-light.webp b/blog/images/20260722-phone-name-light.webp new file mode 100644 index 0000000000..aa9f81918f Binary files /dev/null and b/blog/images/20260722-phone-name-light.webp differ diff --git a/blog/images/20260722-phone-name.webp b/blog/images/20260722-phone-name.webp new file mode 100644 index 0000000000..df812bead6 Binary files /dev/null and b/blog/images/20260722-phone-name.webp differ diff --git a/blog/images/20260722-register-name.png b/blog/images/20260722-register-name.png new file mode 100644 index 0000000000..d64bb21507 Binary files /dev/null and b/blog/images/20260722-register-name.png differ diff --git a/docs/FAQ.md b/docs/FAQ.md index 8c14168811..21cb08417f 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -21,6 +21,7 @@ revision: 13.08.2025 - [Why cannot I delete messages I sent from my contact's device?](#why-cannot-i-delete-messages-i-sent-from-my-contacts-device) - [What do group roles mean?](#what-do-group-roles-mean) - [I don't want to share a web link or a QR code. How can I connect?](#i-dont-want-to-share-a-web-link-or-a-qr-code-how-can-i-connect) +- [How to change servers to my own](#how-to-change-servers-to-my-own) [Troubleshooting](#troubleshooting) - [I do not receive messages or message notifications](#i-do-not-receive-messages-or-message-notifications) @@ -175,6 +176,16 @@ For example, this link: `https://smp18.simplex.im/i#E74vSxMwDnEx6DAvRCZmzBeZwwAs becomes: `simplex:/i#E74vSxMwDnEx6DAvRCZmzBeZwwAseJUD/yVTHjaaH_EzL19DG7fvd46Mjry3IBqYT0UMo5G7l4jQ?h=smp18.simplex.im` +### How to change servers to my own? + +Add your server in Settings -> Network & servers -> Your servers. If you have multiple profiles, you need to add your server in every profile. +After that, disable all preset servers. If you have multiple profiles, you must first add your server to all profiles for this option to be available. + +To resolve SimpleX public names, if your server does not support it, you may leave preset servers enabled only for resolving the public names. + +Please note: configured servers only determine which servers will be used for the new connections (members/contacts). Previously created connections don't switch automatically. You can manually switch all existing contacts and small/important groups (each member needs to be switched), and then leave and re-join all large groups. +You can switch contacts/members manually to another server by going to their profile and clicking on "Change receiving address", note that the current server must be online for the address change to work. + ## Troubleshooting ### I do not receive messages or message notifications diff --git a/docs/guide/register-simplex-name.md b/docs/guide/register-simplex-name.md index df78b19844..366661b7e5 100644 --- a/docs/guide/register-simplex-name.md +++ b/docs/guide/register-simplex-name.md @@ -90,9 +90,9 @@ finishes the setup. Set the name on the same profile the record points at. -- For your **contact address**, open your SimpleX address, tap **Your SimpleX +- For your **contact address**, open your SimpleX address, tap **Get SimpleX name**, enter the name, and save. -- For your **channel**, open the channel information, tap **SimpleX name** (below +- For your **channel**, open the channel information, tap **Get SimpleX name** (under **Channel link**), enter the name, and save. This proves to people who connect to you that `#name.testing` (or `@name.testing`) is your name. diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index a90553a062..b672a78d9b 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.0-beta.4", + "version": "7.0.0-beta.5", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 70758b0c0e..967dd836c0 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.0.0-beta.4'; +const RELEASE_TAG = 'v7.0.0-beta.5'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 39627b657e..7bc0344c03 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.0.0b4" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.0-beta.4" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.0.0b5" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.0.0-beta.5" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/plans/2026-06-09-support-chat-unread-on-group-read.md b/plans/2026-06-09-support-chat-unread-on-group-read.md new file mode 100644 index 0000000000..5d24cc5ff1 --- /dev/null +++ b/plans/2026-06-09-support-chat-unread-on-group-read.md @@ -0,0 +1,200 @@ +# Fix: support-chat unread stats not cleared after no-scope group read + +Branch: `nd/fix-support-chat-unread-on-group-read` (off `origin/master`) + +## Symptom + +In a group with member admission ("chat with admins"), some members keep showing +as unread in the *chats with members* list even after the admin opens their support +chat and reads everything. Reopening never clears them. It is most visible for +members who have already been given a role (e.g. `member`), because pending members +always render the flag icon anyway, so the stuck badge is invisible for them. + +## Root cause + +The *chats with members* badge is driven by the per-member counters +`support_chat_items_unread / _member_attention / _mentions` on `group_members` +(surfaced as `GroupSupportChat`, `Types.hs:1087`). These are denormalized counters, +maintained by increment on receive and decrement on read. + +Reading a group **without a scope** (`APIChatRead` with `scope = Nothing`, +`Commands.hs:1153-1166`) runs `updateGroupChatItemsRead` (`Store/Messages.hs:2078`), +whose `UPDATE` filtered only by `group_id`: + +```sql +UPDATE chat_items SET item_status = RcvRead, ... +WHERE user_id = ? AND group_id = ? AND item_status = RcvNew +``` + +This flips **support-scope** items to `RcvRead` too, but never touches the +`support_chat_items_*` counters. The result is a permanent desync: + +- the items are read (so the chat "looks" fully read), but +- the per-member counters stay > 0 (so the member stays "unread" in the list), and +- reopening the support chat can't fix it: the scoped read decrement + (`updateGroupScopeUnreadStats`, `Store/Messages.hs:2202`) only fires on a real + `RcvNew -> RcvRead` transition, and there are no `RcvNew` items left. + +This is inconsistent with how the group's main unread **count** is computed, which +already excludes support items (`Store/Messages.hs:916`, +`... AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL`). The read +path simply forgot the same predicate. + +Note: `member_attention` self-heals on open via `checkSupportChatAttention` +(`Commands.hs:593-613`), recomputing it from loaded items. `unread` and `mentions` +have **no** such correction, which is why the badge persists. + +## Fix (Option A — prevent new corruption) + +Add the main-scope predicate to the two queries in the no-scope group-read path so +that reading a group without a scope leaves support-scope items (and their +disappearing-message timers) entirely untouched: + +- `updateGroupChatItemsRead` (`Store/Messages.hs`) — the read itself. +- `getGroupUnreadTimedItems`, `Nothing` branch (`Store/Messages.hs`) — its companion + timed-items query. + +```sql +AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL +``` + +Both lines are required and neither is droppable: + +- Without the read filter, support items get marked read (the bug). +- Without the timed-items filter, a disappearing support message would have its + delete timer started by a main-group read while still unread — a *new* + inconsistency introduced by a half-fix. The no-scope `APIChatRead` handler + (`Commands.hs:1160-1166`) calls `getGroupUnreadTimedItems` then + `setGroupChatItemsDeleteAt` then starts deletion threads, so both must agree that + support items are out of scope. + +Support items continue to be read (and their counters decremented) the correct way: +via the scoped read paths (`updateSupportChatItemsRead` / `updateGroupChatItemsReadList` +with a `MemberSupport` scope), exercised when the support chat is opened. + +### Why this is consistent / safe + +- Selects exactly the main-scope rows, identical to the authoritative unread-count + query (`Store/Messages.hs:916`). No main item is ever wrongly excluded — inserts + always set `group_scope_tag` and `group_scope_group_member_id` together + (`Store/Messages.hs:600-603`). +- `groups.members_require_attention` becomes *more* consistent, not less: the + no-scope read no longer zeroes support items behind a still-positive group counter. +- The added predicate is constant, parameterized SQL — no injection surface — and is + the existing codebase idiom (916 / 1214 / 1524 / 1745). + +## Test + +`tests/ChatTests/Groups.hs :: testScopedSupportUnreadStatsGroupReadNoScope` +(registered under the support-chat `describe` block). + +A member sends one support message (`markRead = False`), the admin marks the whole +group read with `/_read chat #1` (no scope), then reads the item in scope with the +**per-item** read `/_read chat items #1(_support:2) `. The per-item read only +decrements on a genuine `RcvNew -> RcvRead` transition, so: + +- on master the no-scope read already consumed the item -> decrement is a no-op -> + `unread: 1, require attention: 1` (test fails); +- with the fix the item is still unread -> decrement applies -> + `unread: 0, require attention: 0` (test passes). + +The full scoped read `/_read chat #1(_support:2)` is deliberately *not* used to +assert this, because `updateSupportChatItemsRead` hard-zeroes the counters +regardless of item state and would mask the bug. + +## Performance + +The added predicate makes both queries **faster**, not slower, and needs no new +index. Verified two ways. + +### Query plan (EXPLAIN QUERY PLAN, via the checked-in plan snapshots) + +Regenerating `chat_query_plans.txt` shows both statements switch index: + +``` +- SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention + (user_id=? AND group_id=? AND item_status=?) -- 3-column seek ++ SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all + (user_id=? AND group_id=? AND group_scope_tag=? AND + group_scope_group_member_id=? AND item_status=?) -- 5-column seek +``` + +SQLite compiles `group_scope_tag IS NULL` / `group_scope_group_member_id IS NULL` +into equality constraints (`=?`), so the two new columns *deepen the index seek* +rather than adding a post-seek filter or scan. The read now uses +`idx_chat_items_group_scope_stats_all` +(`user_id, group_id, group_scope_tag, group_scope_group_member_id, item_status, …`, +`M20250721_indexes.hs`), which already exists — this is the same index the +authoritative unread-count query uses. No migration, no new index. + +Note: master was **not** degraded here — it already got a full 3-column seek via +`idx_chat_items_groups_user_mention`. So this is a modest improvement, not the repair +of a regression. The plan file only records two hunks (the two flipped statements); +the `saveQueryPlans` test also churns an unrelated `group_members` count entry and the +whole `agent_query_plans.txt`, both of which were reverted to keep the diff to exactly +those two queries. + +### Micro-benchmark (synthetic 200k-row group) + +On a synthetic group of ~200k `chat_items` where ~25% are support-scope, running the +old vs. new `UPDATE` in alternating trials: + +| Query | mean wall-clock | +|-----------------|-----------------| +| old (no filter) | ~10.5 s | +| new (scoped) | ~7.4 s (≈ −29%) | + +The speedup tracks the row reduction: the new query touches only the ~75% main-scope +rows, so it does ~25% less work and the tighter seek trims a bit more. This also +directly demonstrates the bug — the old query flips ~50k support rows to `RcvRead` +(without decrementing counters); the new one flips **0**. + +Caveat: absolute timings were noisy (concurrent GHC builds on the box); only the +*relative* comparison across alternating trials is reliable, and it was consistent in +direction and magnitude across runs. The gain scales with the support-item share — a +group with no support chats sees no measurable change. + +## Limitations (accepted, by decision) + +1. **No retroactive repair.** This change only prevents *new* corruption. Rows already + stuck (counter > 0 while the items are `RcvRead`) stay stuck until either the + per-member "Mark read" action (`apiSupportChatRead` -> `updateSupportChatItemsRead`, + which hard-zeroes) or, for `member_attention` only, the self-heal on open + (`checkSupportChatAttention`). A one-shot migration recomputing the three counters + from `chat_items` was considered and **intentionally not done**. + +2. **`setUserChatsRead` is not fixed.** The global "mark all chats read" + (`APIUserRead` -> `setUserChatsRead`, `Store/Direct.hs:640-646`) has the same flaw: + its `UPDATE chat_items ... WHERE user_id = ? AND item_status = ?` has no chat-type + and no scope filter, so it reads support items user-wide without decrementing the + counters. It is **left as-is on purpose**: no GUI client invokes `APIUserRead` + (it exists only as the Haskell command type `Controller.hs:357` and the CLI + binding), so it is not reachable from Android / desktop / iOS — only the terminal + CLI. The same one-line predicate would close it if a client ever exposes it. + +3. **`getUpdateGroupItem` relies on a UI contract.** The per-item read + (`updateGroupChatItemsReadList` -> `getUpdateGroupItem`, `Store/Messages.hs:2178`) + has no scope filter on its `UPDATE` and its `Nothing` branch decrements nothing, so + passing a support item ID with `scope = Nothing` would reproduce the desync. This is + defended only by the in-code assumption ("we rely on UI to not pass item IDs from + incorrect scope", `Store/Messages.hs:2162-2163`), not by the query. Not reachable in + normal flow; left unchanged to keep the diff surgical. + +## Files changed + +- `src/Simplex/Chat/Store/Messages.hs` — two one-line scope predicates. +- `tests/ChatTests/Groups.hs` — one regression test + its `it` registration. + +## Build & test run + +Library compiled clean via `bash /home/user/build/linux.sh`. The full test suite was +subsequently built and run against this branch (968 examples). The regression test +`should not read support chat items when reading group without scope` passes with the +fix and fails when the fix is reverted (confirmed by rebuilding master and re-running), +so it genuinely catches the desync. + +The suite had 8 failures on the run; each was classified as flaky/environmental, +by-design, or pre-existing on master — none attributable to this change. In particular +the timed-message failure (`both users have configured timed messages … restart`) was +reproduced identically on master with the fix reverted (3/3), proving it pre-existing; +it is a direct-chat test that never touches the group-scope read path. diff --git a/plans/2026-06-17-message-info-file-xftp-servers.md b/plans/2026-06-17-message-info-file-xftp-servers.md new file mode 100644 index 0000000000..f7803984cf --- /dev/null +++ b/plans/2026-06-17-message-info-file-xftp-servers.md @@ -0,0 +1,140 @@ +# Plan: Show XFTP servers used for a file in Message Info + +## Goal +In the message-info screen, when the message contains a file, show the list of XFTP servers that hosted the file's chunks ("servers used to upload the file"). + +## Why this is needed +For transparency: a recipient downloads a file's chunks from XFTP relays without any visible indication of which servers those are. Surfacing them in message info lets a user see exactly what servers they are downloading from (and, for sent files, uploading to) whenever they want to know — useful for trust, debugging, and deciding whether to download. + +## Decisions (confirmed) +- **Direction:** Both sent and received files. +- **Visibility:** Always shown when the item has a file (not gated behind Developer tools). +- **Platform:** Android, desktop, and iOS. + +## Why this needs a core change (not Kotlin-only) +The server list is **already known to the app** — the file description (which lists each chunk's XFTP server replicas) is what the agent uses to upload/download. But that data lives in the **core (Haskell) layer**: it's stored in the `files` table (`private_snd_file_descr` for sent, the rcv file descr row for received) and parsed inside the core. It is **never surfaced across the core→client API boundary**: the client-facing `CIFile` and `ChatItemInfo` types carry no server info. So this is a **plumbing task** (expose existing data through the API response), not an algorithmic one. The core already has the extraction helper. + +## Existing building blocks (no new logic needed) +- `Internal.hs:764-766` — `fileServers` extracts the unique `[XFTPServer]` from a parsed description's chunk replicas. Currently a local `where` helper; will be lifted to a reusable top-level function. +- `Internal.hs:747` — `parseFileDescription :: Text -> CM (ValidFileDescription 'FRecipient)`. +- `Store/Files.hs:42` — `getRcvFileDescrByRcvFileId` returns `RcvFileDescr {fileDescrText, fileDescrComplete}` (received files). +- `Store/Files.hs:197` — `setSndFTPrivateSndDescr` stores `private_snd_file_descr` (sent files). A matching getter must be added (none exists today). +- `XFTPServer` already JSON-encodes to a string on the wire (see `XFTPServerSummary.xftpServer :: XFTPServer` → Kotlin `xftpServer: String`), so the Kotlin field can be `List`. + +--- + +## Implementation + +### 1. Core — extraction helper (`src/Simplex/Chat/Library/Internal.hs`) +- Lift `fileServers` out of `receiveViaCompleteFD`'s `where` block to a top-level function, generalized to work on any party's description chunks: + ```haskell + fileDescrServers :: FD.FileDescription p -> [XFTPServer] + fileDescrServers FD.FileDescription {chunks} = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + ``` + (Keep `receiveViaCompleteFD` working by calling the lifted helper.) +- Add a convenience that resolves a file's servers by direction, returning `[]` when no XFTP description exists (inline/legacy files): + ```haskell + getChatItemFileServers :: User -> ChatItem c d -> CM [XFTPServer] + ``` + - For **received** (`SMDRcv`) with an XFTP file: `getRcvFileDescrByRcvFileId` → `parseFileDescription` → `fileDescrServers`. + - For **sent** (`SMDSnd`) with an XFTP file: read `private_snd_file_descr` (new getter) → parse the sender description → `fileDescrServers`. + - Guard on `fileProtocol == FPXFTP`; wrap parse in tolerant error handling so a missing/partial descr yields `[]` (no crash, no section). + +### 2. Core — store getter (`src/Simplex/Chat/Store/Files.hs`) +- Add `getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text)` reading `private_snd_file_descr` from `files` (mirror of `setSndFTPrivateSndDescr`; column already selected at `Files.hs:799`). Export it. + +### 3. Core — extend `ChatItemInfo` (`src/Simplex/Chat/Messages.hs`) +- Add field: + ```haskell + data ChatItemInfo = ChatItemInfo + { itemVersions :: [ChatItemVersion], + memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] -- NEW (empty when no file / not XFTP) + } + ``` +- `$(JQ.deriveJSON defaultJSON ''ChatItemInfo)` at `Messages.hs:1531` regenerates the instance automatically. Import `XFTPServer` if not already in scope. +- Note: using `[XFTPServer]` (not `Maybe`) keeps the JSON additive and the Kotlin side simple; empty list = nothing to show. + +### 4. Core — populate in handler (`src/Simplex/Chat/Library/Commands.hs:619`) +- In `APIGetChatItemInfo`, after computing `forwardedFromChatItem`: + ```haskell + fileXftpServers <- getChatItemFileServers user ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} + ``` + +### 5. Kotlin model (`apps/multiplatform/.../model/ChatModel.kt:5207`) +```kotlin +class ChatItemInfo( + val itemVersions: List, + val memberDeliveryStatuses: List?, + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() // NEW; server hosts as strings +) +``` +(`XFTPServer` serializes to a string, matching the existing `XFTPServerSummary.xftpServer: String`.) + +### 6. Desktop/shared UI (`apps/multiplatform/.../views/chat/ChatItemInfoView.kt`) +- In `Details()` (`:250-283`), after the existing file-status block, add (always-visible, when present): + ```kotlin + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + // section header + one InfoRow/host per server + } + ``` + - Render as its own `SectionView` with a title (e.g. "File servers") and one row per server host, styled like the existing info rows. For long host strings, single-line with ellipsis (consistent with the long-name handling already on this branch). +- Add the servers to the shareable text in `itemInfoShareText` (`:534-580`), near the existing file-status share line (`:564-565`), so "Share" / copy includes them. + +### 7. Translations (additive only — never rename/remove keys) +- Add one new key to `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`, e.g.: + ```xml + File servers + ``` + Base locale only; Weblate fans out to other locales. Do not touch existing keys. + +### 8. iOS model (`apps/ios/SimpleXChat/ChatTypes.swift:5773`) +Same core change (shared `libsimplex`), so iOS only needs the Swift model + view. Add the optional field (optional so an absent key never breaks decoding): +```swift +public struct ChatItemInfo: Decodable, Hashable { + public var itemVersions: [ChatItemVersion] + public var memberDeliveryStatuses: [MemberDeliveryStatus]? + public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? // NEW +} +``` + +### 9. iOS UI (`apps/ios/Shared/Views/Chat/ChatItemInfoView.swift`) +- In `details()`, after the `developerTools` block (always-visible when present): + ```swift + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } + ``` + `serverHostname(_:)` is public in `SimpleXChat` (`ErrorAlert.swift:142`). +- Add the same to `itemInfoShareText()` (joined with ", "). Strings are inline `NSLocalizedString` (no separate resource file to edit). + +--- + +## Edge cases / behavior +- **Inline / legacy files** (no XFTP description): `fileXftpServers == []` → section hidden. No crash. +- **Sent file before upload completes** (`private_snd_file_descr` not yet set): `[]` → hidden until available. +- **Received file not yet accepted/downloaded:** the rcv description exists as soon as the offer is received (it's what enables download), so servers are available even before download. ✓ +- **Multiple chunks across different servers:** deduped via `S.fromList`; all distinct servers listed. +- **Large files (any size up to the limits):** servers are accurate regardless of size. Chat-message files **never** use XFTP redirect — the description is split across multiple `XMsgFileDescr` messages (`splitFileDescr`) and reassembled in full by the recipient (`appendRcvFD`), so a received chat file always carries the real data-chunk servers. (`maxFileSize` = 1 GB soft, `maxFileSizeHard` = 5 GB; XFTP chunk sizes 64 KB / 256 KB / 1 MB / 4 MB.) +- **Redirect descriptions:** XFTP redirect (a small `redirect = Just` description whose chunks point to the relay hosting the real description) is used **only** for *standalone file links* (`SFDONE` `Nothing` branch → `xftpSndFileRedirect`), which have no chat item and never appear in message info. The chat-message branch never redirects, so no redirect description can reach `getChatItemFileServers` — no guard is needed. + +## Build / verification +- **Heaviest step:** rebuild the native core lib (`libsimplex`) consumed by the multiplatform app, since core types changed. Without the rebuild, the new JSON field is simply ignored by the old lib (the Kotlin default `emptyList()` keeps it backward-compatible — section just won't appear). +- Tests: a core unit/integration check that `APIGetChatItemInfo` returns a non-empty `fileXftpServers` for an XFTP file (sent and received), empty for a text message and for an inline file. +- Manual: send a file → open message info → confirm the server list; repeat on the receiving device for the received item. + +## Touch list +- `src/Simplex/Chat/Library/Internal.hs` — lift `fileDescrServers`, add `getChatItemFileServers`. +- `src/Simplex/Chat/Store/Files.hs` — add `getSndFTPrivateSndDescr` (+ export). +- `src/Simplex/Chat/Messages.hs` — add `fileXftpServers` to `ChatItemInfo`. +- `src/Simplex/Chat/Library/Commands.hs` — populate it in `APIGetChatItemInfo`. +- `apps/multiplatform/.../model/ChatModel.kt` — add Kotlin field. +- `apps/multiplatform/.../views/chat/ChatItemInfoView.kt` — UI section + share text. +- `apps/multiplatform/.../resources/MR/base/strings.xml` — new label key. +- `apps/ios/SimpleXChat/ChatTypes.swift` — add `fileXftpServers` to the Swift `ChatItemInfo`. +- `apps/ios/Shared/Views/Chat/ChatItemInfoView.swift` — UI row in `details()` + `itemInfoShareText()`. diff --git a/plans/2026-06-22-fix-card-section-stray-dividers.md b/plans/2026-06-22-fix-card-section-stray-dividers.md new file mode 100644 index 0000000000..72beec92bd --- /dev/null +++ b/plans/2026-06-22-fix-card-section-stray-dividers.md @@ -0,0 +1,40 @@ +# Fix stray card dividers in server info, connect-to-desktop, appearance and migration screens + +Branch: `nd/fix-ui-lines` · base: `master`. + +## Problem + +On several card screens (Android and desktop, current `master`), near-black horizontal lines appear inside section cards where they don't belong — most visible in dark/black themes: + +- **Servers info → tap an SMP or XFTP server**: the server-detail card is sliced by stray lines around its nested stats / subscriptions / sessions blocks. +- **Use from desktop → "Connected to desktop"** (also the connecting / found / verify states): two black lines between the desktop name and its version (e.g. "Desktop" and "v7.0.0.1"). +- **Settings → Appearance** with a custom image wallpaper: a double line with a gap between "Remove image" and "Color mode". +- **Migrate from this device**, "error stopping chat" state: a double line between the error text and the stop-chat action. + +Behaviour is correct everywhere; the bugs are purely visual. + +## Cause + +PR #6777 (`df5ea3d46`, "android, desktop: new settings section design") wrapped `SectionView`'s content in `CardColumnLayout`, which draws a 2 dp divider (`canvasColorForCurrentTheme()`, near-black in dark/black themes) at the bottom of every direct child except the last. The same PR turned these screens into card screens (`ModalView(cardScreen = true)`) but left three content patterns that pre-date the card chrome, each of which now produces stray lines: + +1. **Nested `SectionView`s** inside one outer `SectionView` (server detail) → card-in-card, plus a line around every nested block and spacer. +2. **Loose `Text` + `Spacer` + `Text`** as separate children of a card `SectionView` (connect-to-desktop name + version) → a line after the text and after the spacer. +3. **`SectionDividerSpaced()` as a middle child** (Appearance, migration) → on a card screen this is a 30 dp `Spacer`, so it gets a line above *and* below = a double line bracketing a gap. + +This is the same bug class as `2026-05-25-fix-e2e-encryption-section-divider.md` (#7012); #6777 reintroduced further instances that earlier fix did not cover. + +## Fix + +`apps/multiplatform/common/...`, layout-only, matching the reference sibling pattern already used by `DetailedSMPStatsLayout`: + +- **`ServersSummaryView.kt`** — `SMPServerSummaryLayout` / `XFTPServerSummaryLayout`: stop wrapping everything in one outer `SectionView`; make the address its own card and each sub-section a top-level sibling separated by `SectionDividerSpaced()` placed *between* them. `SMPSubscriptionsSection`'s rows are wrapped in their own `SectionView` so they keep card chrome. +- **`ConnectDesktopView.kt`** — wrap each device-name + version block in a single `Column` (4 states), so the card sees one child and draws no internal divider. +- **`Appearance.kt`** / **`MigrateFromDevice.kt`** — delete the in-card `SectionDividerSpaced()`; the auto-divider between the two now-adjacent rows already separates them (the #6777 precedent for in-card spacers). + +## Risk + +- Layout-only: no logic, state, side-effect, or click-handler change. +- iOS is a separate codebase (native grouped lists) and is unaffected. +- Verified: `:common:compileKotlinDesktop` and the full desktop AppImage build succeed, and the running app renders the screens correctly. Independent adversarial review confirmed every conditional branch and separator is preserved across all stats/subs/sessions combinations. +- Deliberately out of scope: `SectionTextFooter`-inside-card instances (NetworkAndServers, Migrate*) — these render as a single normal row-separator above an in-card caption, not the stray-line bug; a separate #6777-style cleanup. +- Rollback: `git revert` the fix commit. diff --git a/plans/2026-07-10-remove-status-preset-contact.md b/plans/2026-07-10-remove-status-preset-contact.md new file mode 100644 index 0000000000..566cf11dd0 --- /dev/null +++ b/plans/2026-07-10-remove-status-preset-contact.md @@ -0,0 +1,41 @@ +# Remove the SimpleX Status preset contact + +Branch: `nd/remove-status-preset-contact` · PR #7231 (supersedes #7200) + +## 1. Problem statement + +Every new profile gets a "SimpleX Status" preset contact card. It is too confusing for non-technical users: it looks like a person or a chat, but it is an automated broadcast bot, and tapping it opens a connect dialog rather than content. On top of that, its stored contact link is the old `simplex:/contact/#/...` invitation address; the intended replacement is a channel, which a preset *contact* card cannot deliver (see §3). + +## 2. Solution summary + +Delete the preset entirely: + +- `simplexStatusContactProfile` removed from `Library/Internal.hs` (14 lines incl. inline logo). +- Its `createContact` call removed from `createPresetContactCards` in `Library/Commands.hs` (one line; the "Ask SimpleX Team" preset stays). +- Test expectations updated mechanically (see §5). + +No migration for existing profiles: preset cards are only ever created inside user record creation (`APICreateActiveUser` → `createPresetContactCards`), so existing profiles keep their stored card, matching how presets have always behaved. Users can still reach the bot via https://status.simplex.chat and the blog. + +## 3. Why not fix the link instead (the #7200 dead end) + +#7200 swapped the preset's link to a channel link (`https://smp5.simplex.im/c#...`). That cannot work: preset cards are contacts, and tapping one runs `APIConnectContactViaAddress` → `prepareContact`/`joinContact` — the direct-contact handshake. Channel short links (`/c#` decodes to `CCTChannel`) must be joined via `APIConnectPreparedGroup`; `APIConnect` explicitly rejects them ("channel links must be connected via APIConnectPreparedGroup"), but the contact-card path has no such guard and silently performs the wrong handshake. Making the status channel a preset would require a preset *prepared channel* — `APIPrepareGroup` needs a full connReq plus `GroupShortLinkData`, run after the user is active — a substantially larger change than wanted, for a card that confuses the users it is shown to. + +## 4. Scope of effect + +- **New profiles**: get only the "Ask SimpleX Team" card. +- **Existing profiles**: unchanged; their stored SimpleX Status contact remains until the user deletes it. +- **Discovery**: this removes the status bot's only in-app discovery path for new users — deliberate, since the card was doing more harm (confusion) than good (discovery). + +## 5. Test impact + +Removing the card shifts contact ids allocated after `/create user` down by one: + +- 10 chat-list / `hasContactProfiles` expectations lose the status entry (`Direct.hs`, `Profiles.hs`). +- 24 numeric contact refs shift: `@6`→`@5` in `Direct.hs`; `@5`→`@4` and `@6`→`@5` in `Profiles.hs`, plus the id-arithmetic comments. +- `configureTimedMessages alice bob "6" "3"` → `"5"` — the contact id travels as a bare string argument, invisible to `@N`-pattern search; caught only by running the tests. + +Verified unaffected: group ids (`#N`), pending-connection ids (`:N` — preset cards create no connection rows), user ids, unread/`/users` counts (cards create no chat items), `MobileTests`/`RemoteTests`/`Local.hs` (harness users bypass presets), display-name allocation (per-user). + +## 6. Verification + +All 8 non-timing-sensitive affected tests pass locally. The 3 TTL/timed-message tests (`testUsersDifferentCIExpirationTTL`, `testUsersRestartCIExpiration`, `testUsersTimedMessages`) fail identically on the base commit `6bb1da9e8` in the same environment — pre-existing cleanup-manager timing flakes (cf. `d7010d527`), not regressions. The packaged desktop build was verified to contain no "SimpleX Status" strings in `libsimplex.so` while retaining "Ask SimpleX Team". diff --git a/plans/2026-07-11-fix-desktop-clipboard-freeze.md b/plans/2026-07-11-fix-desktop-clipboard-freeze.md new file mode 100644 index 0000000000..59d357446e --- /dev/null +++ b/plans/2026-07-11-fix-desktop-clipboard-freeze.md @@ -0,0 +1,61 @@ +# Fix desktop UI freeze caused by clipboard polling; remove dead `clipboardHasText` state + +## Problem + +User report: the Linux desktop app becomes extremely slow — every scroll or button press takes ~9 seconds. The lag disappears while the clipboard is filled and returns when it is emptied, reproducibly triggered by KeePassXC's clipboard auto-clear (10s safety timeout). Manual clearing via desktop tools does not trigger it. + +## Root cause + +`SetupClipboardListener()` (desktop actual, `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt`) ran `chatModel.clipboardHasText.value = clipboard.hasText()` once at composition and then every 1 second in a `LaunchedEffect` — i.e. on the Compose main dispatcher, which on desktop is the AWT Event Dispatch Thread that processes all input and rendering. + +The call chain of `hasText()` (verified in Compose 1.8.2 / skiko 0.9.4 bytecode): + +``` +PlatformClipboardManager.hasText() + → org.jetbrains.skiko.ClipboardManager_hasText() + → ClipboardManager_getText() + → java.awt.datatransfer.Clipboard.getData(DataFlavor.stringFlavor) +``` + +So "does the clipboard have text" was answered by fetching the full clipboard contents over an X11 selection conversion, every second. On X11, `getData` blocks in `sun.awt.X11.XSelection.waitForSelectionNotify()` until the selection owner replies or `sun.awt.datatransfer.timeout` expires — **default 10 000 ms** (OpenJDK `UNIXToolkit.getDatatransferTimeout()`). + +When the X11 CLIPBOARD selection owner does not answer conversion requests — the state KeePassXC's auto-clear leaves behind — each poll blocked the EDT for the full 10 s, then the loop slept 1 s and blocked again. The EDT was therefore blocked ~10 of every ~11 seconds; a random click or scroll waited ~9 s on average, exactly as reported. Any responsive clipboard owner (any normal app copying text) made the polls instant again, which is why filling the clipboard "cured" it. + +## Why deletion (not a workaround) is correct + +`chatModel.clipboardHasText` has had **zero readers since July 2024**: its only ever consumer — a conditional paste icon over the chat list search field — was removed in #4398 (commit `3e623684b`). Everything else was write-only plumbing for that state: + +- desktop 1s poll (`Utils.desktop.kt`) — the freeze source; +- Android `addPrimaryClipChangedListener` (`Utils.android.kt`, added in #3529); +- Android `onResume` re-read of `hasPrimaryClip()` (`MainActivity.kt`, #3758) — a workaround for Android 10+ denying clipboard access to backgrounded apps, needed only to keep the state fresh for that same paste icon; +- the `expect`/`actual` declarations and the `App.kt` call site; +- the `ChatModel.clipboardHasText` field itself. + +Verified before removal: + +- No direct reads anywhere in the repo (all source sets, all modules), on any remote branch, and none in any upstream PR (GitHub code search: only the writers + one spec table row). +- No indirect access: no reflection over `ChatModel`, no `"clipboardHasText"` string literal, no wholesale snapshot observation, no serialization (`ChatModel` is `@Stable`, not `@Serializable`), nothing crosses the mobile↔desktop remote protocol, no Gradle/lint/ProGuard/codegen references, no tests. +- Removing the `App.kt` call cannot affect sibling composition: the composable emits no UI node (Android: bare `DisposableEffect`; desktop: state writes only), and there is no `key()`/`movableContentOf`/positional logic nearby. +- Android side effects: none. `hasPrimaryClip()` does not read clip contents (no Android 12+ access toast); this was the only primary-clip listener (no ordering concerns); the `onResume` coroutine `Job` was never captured or awaited. +- All remaining paste features are unaffected because they read the clipboard directly at the moment of user action, not via this state: "tap to paste link" (`NewChatView.kt`), Ctrl+V in the composer (`PlatformTextField.desktop.kt`), "paste desktop address" (`ConnectDesktopView.kt`), migration link paste (`MigrateToDevice.kt`). + +After this change the desktop app touches the X11 clipboard only on explicit user paste, so no background clipboard state can stall the UI. (A paste attempt while the selection owner is unresponsive can still block once, up to the AWT timeout — inherent to AWT's synchronous X11 clipboard, out of scope.) + +## Change + +Delete the dead mechanism end to end: + +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt` — `expect fun SetupClipboardListener()` +- `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt` — desktop actual (the polling loop) + unused imports +- `common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt` — Android actual (clip listener) + unused imports +- `common/src/commonMain/kotlin/chat/simplex/common/App.kt` — call site +- `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` — `clipboardHasText` field +- `android/src/main/java/chat/simplex/app/MainActivity.kt` — `onResume` clipboard block + unused imports +- `spec/state.md` — stale table row +- `spec/client/navigation.md` — stale "sets up clipboard listener" step in the MainScreen description + +## Verification + +- `:common:compileKotlinDesktop`, `:common:compileReleaseKotlinAndroid`, `:android:compileReleaseKotlin` pass. +- Repo-wide grep for `clipboardHasText`/`SetupClipboardListener` returns no references outside this document. +- Linux AppImage and Android APK built from this branch. diff --git a/plans/2026-07-13-fix-command-hover-cursor.md b/plans/2026-07-13-fix-command-hover-cursor.md new file mode 100644 index 0000000000..6f96abeee6 --- /dev/null +++ b/plans/2026-07-13-fix-command-hover-cursor.md @@ -0,0 +1,240 @@ +# Fix: hand cursor not appearing over clickable commands (desktop) + +## Problem + +On desktop, the mouse cursor should change to a hand over clickable commands in chat messages +(e.g. `/join 143556` in a bot's menu of commands) — the hand is what tells the user the command +can be clicked. This did not work reliably: hovering a command sometimes left the text/arrow +cursor in place, giving no indication that the command is clickable. It usually worked, but failed +intermittently — most often when clicking many commands in a row in a chat, because every click +inserts the sent message and shifts the list under the pointer — and once wrong, the cursor stayed +wrong even while moving within the same message; it only recovered after leaving the message text +and re-entering it. The same mechanism affects all clickable elements in message text (links, +simplex addresses, secrets), but command menus made it most visible. + +A second user-visible problem surfaced while testing this fix: a click on a command was sometimes +not registered at all when clicking two commands (e.g. two `/join`s) in short succession. That is +defect 3 below — two independent mechanisms in the click path, unrelated to the cursor layers. + +## Investigation + +The cursor is driven by two cooperating layers in `ClickableText` (TextItemView.kt): + +1. **Detection** — `Modifier.pointerInput { detectCursorMove { ... } }` maps the pointer position to + a character offset and checks for `COMMAND`/link annotations, storing `PointerIcon.Hand` or + `PointerIcon.Text` in per-item state (`onHover`). +2. **Display** — `Modifier.pointerHoverIcon(icon.value)` tells Compose which cursor to show. + +Both layers were traced against the Compose Multiplatform 1.8.2 sources (`ui-desktop`, +`foundation-desktop`). + +### Defect 1: detection layer lost events (commit "fix pointer cursor not changing to hand…") + +`detectCursorMove` (GestureDetector.kt) processed **one pointer event per +`awaitPointerEventScope` block**, exiting and re-entering the scope through `forEachGesture` for +every event. `forEachGesture` is deprecated in this exact Compose version with the message *"Use +awaitEachGesture instead. forEachGesture() can drop events between gestures."* — so the final Move +event, the one that should switch the cursor when the pointer comes to rest on a command, could be +silently dropped. It also ignored `Enter` events entirely, so when the chat list shifted under a +stationary cursor (every command click appends the sent message), the newly hovered item — which +receives `Enter`, not `Move` — never updated the icon state. + +**Fix:** one never-exiting `awaitPointerEventScope` loop reacting to `Move`, `Enter` and `Release` +(plus an optional `onExit` callback used by the cursor fix below). `Release` refreshes hover at +the final pointer position — after clicking a command the list shifts and the release is the only +event a stationary pointer gets. Button-held events are ignored (gated on *no* pointer pressed, +matching the old `forEachGesture` semantics which waited for all pointers up between events), and +so are out-of-bounds positions: while a button is pressed the hit path is locked, so the pressed +node keeps receiving events after the pointer leaves it — acting on those would apply hover +effects at clamped positions (e.g. a wrong cursor stuck after drag-releasing outside the text). +The other `detectCursorMove` call sites (scrollbar reveal in `ScrollableColumn.desktop.kt` and +`OperatorView.desktop.kt`) were audited: reacting to `Enter`/`Release` there is harmless or an +improvement, and `onExit` defaults to a no-op for them. + +### Defect 2: display layer silently swallows updates (commit "set hover cursor directly…") + +This made hovering reliable but the cursor could still get stuck, because Compose's +`pointerHoverIcon` display side is edge-triggered with several silent-drop guards +(all confirmed in the CMP 1.8.2 sources): + +- `HoverIconModifierNode` acts only on per-node `Enter`/`Exit` events (`PointerIcon.kt:253-259`); + Move events never refresh the displayed cursor. +- Its `icon` setter displays only if the value *changed* **and** the node is marked in-bounds + (`PointerIcon.kt:213-221`) — re-asserting the same value, or changing it while the node believes + the cursor is outside, is silently dropped. +- `setIcon` writes the AWT cursor immediately, last-write-wins, with no later reconciliation + (`RootNodeOwner.skiko.kt:738-744`); `onDetach`/`onCancelPointerInput` reset it to default from + recomposition, outside pointer dispatch (`PointerIcon.kt:278-288`). +- Whether a node gets `Enter` depends on `hasEntered` state stored separately in + `HitPathTracker.Node` (`HitPathTracker.kt:582-594`); confirmed windows exist where the two + desync (detach between `buildCache` and dispatch; `cleanUpHover` clearing `isIn` without + dispatching; Press/Release never remapping to Enter). Once desynced, the node receives only + Moves — which it ignores — so the hand can never be displayed again until the pointer physically + leaves and re-enters the text. +- Recovery after content shifts under a stationary cursor depends entirely on the synthetic Move + (`SyntheticEventSender`), which is skipped whenever the sender was reset or + `needUpdatePointerPosition` was not set for that frame. + +Clicking many commands maximizes exposure: each click inserts a message → recomposition burst + +list shift + (for `/join`) group-state changes — each transition can hit one of these edges. +Related upstream issues: JetBrains/compose-multiplatform #2091, #1314, #3750. + +**Fix (initial):** in `onHover` — which now fires reliably on every Move/Enter/Release over the +text — set the AWT cursor imperatively on desktop (`desktopSetHoverCursor`, writing to the same +Skia canvas component Compose writes to), and symmetrically reset both the cursor and the `icon` +state on the per-node `Exit` event. + +**Removed after defect 5 was fixed at the root.** The empirical justification for the imperative +layer (stage 1 under Testing) was contaminated: that test ran while defect 5 was live, i.e. while +every message insertion reset every hover handler in the viewport — which alone explains the +observed failure (a reset suspending handler restarts only on the next pointer event, leaving a +stationary cursor blind after every click). With defects 1 and 5 fixed, the full hover matrix +(rapid stationary-mouse command clicks, list shifting under a hovered pointer, fast sweeps across +commands/links/text, press-drag-out-release-re-enter, wheel-scroll over commands) passes without +the imperative layer, so it was deleted; `Modifier.kt`/`Modifier.desktop.kt`/`Modifier.android.kt` +are untouched by this PR again. What remains of this defect's fix is the `icon.value = Text` reset +on `Exit` in `MarkdownText` — without it, `pointerHoverIcon` re-displays a stale Hand on the next +Enter. The display-layer edges documented above are real in the sources, but with stable handlers +they are not practically reachable in these scenarios. + +### Residual limitation (known, strictly smaller than the fixed bug) + +If a hovered clickable text leaves the composition without the pointer ever getting an `Exit` +event (e.g. the hovered message is deleted, or a fast fling disposes the item within a frame), the +`onExit` reset does not run — the coroutine is simply cancelled. The retained `pointerHoverIcon` +modifier's `onDetach` reset covers this when its node is in the normal (non-desynced) state; in the +rare desynced state the cursor may stay a hand until the next hover write. This is the same event +class the fix reduces, bounded to one stale frame region, and self-corrects on any subsequent +hover. + +### Defect 3: command clicks intermittently lost (commit "multiplatform: fix command clicks lost on quick successive clicks") + +Clicking two commands in short succession sometimes lost a click. Two independent mechanisms, +each reproduced in an isolated harness before fixing and re-verified fixed with the same harness: + +1. **Press-scope race in `detectGesture`** (GestureDetector.kt — a 2022 copy of + `detectTapGestures` predating upstream's hardening; the file has not been resynced since). + The single `PressGestureScopeImpl` is shared across gestures, and its methods run in two + different lanes of the UI thread: `reset()`/`release()` synchronously inside pointer-event + dispatch, while the `onPress` handler (which awaits release, then calls `onClick`) runs in a + launched coroutine resumed through the dispatcher queue. With the old non-suspending + `reset()` (`mutex.tryLock()`), two fast clicks interleave as: up₁ `release()` sets + `isReleased` and unlocks — the first click's `tryAwaitRelease` resumption is only *queued* — + then down₂ `reset()` clears `isReleased` synchronously, so the queued resumption reads + `false` and the first click's `onClick` never fires. Reproduced deterministically in a + single-threaded coroutine harness replaying this ordering (`click1=false click2=true`). + + **Fix:** mirror the `detectTapGestures` serialization from Compose 1.8.2 (`foundation` + `TapGestureDetector.kt`): `reset()` is `suspend` and takes the mutex, so a new gesture cannot + clear the flags until the previous gesture's press handler finished; `release()`/`cancel()` + unlock guardedly (`if (mutex.isLocked)`) and are launched joining the reset job, so flag + writes cannot be reordered across gestures; `tryAwaitRelease()` releases the mutex after + acquiring it, which is what lets the next `reset()` proceed. The mutex becomes a + serialization token between consecutive gestures. `detectGesture` also runs for Android + touch, so the same quick-tap loss is fixed there. + +2. **`pointerInput` restart swallowing an in-flight click** (TextItemView.kt). `ClickableText` + keyed `pointerInput(onClick, onLongClick)` on lambdas that are new instances every + recomposition, so any recomposition of the message restarted the gesture coroutine and + destroyed a gesture in flight: the cancelled `onPress` never resumes, and the restarted + detector waits for a fresh down that never comes for the press already held. Clicking + command 1 sends a message whose insertion recomposes the item 50–300 ms later — exactly when + the second click tends to be pressed. Reproduced in a headless `ImageComposeScene` driving + the verbatim repo gesture code with synthetic pointer events: press → recomposition applies → + release lost the click, while control clicks on either side registered. + + **Fix:** key both `pointerInput` blocks on `Unit` and read the latest handlers through + `rememberUpdatedState` (the standard idiom for long-lived event coroutines). This also stops + recompositions from cancelling `detectCursorMove` mid-stream — the same event-loss class as + defect 1 — and gives all `ClickableText` callers latest-capture semantics for + `annotatedText` (previously the handlers could act on a stale capture until the restart). + +A theoretical residue remains: if three gestures' pointer events were all processed before the +dispatcher ran any of the second gesture's jobs, flags could still mispair. This exposure is +structurally identical in upstream Compose 1.8.2 (`launchAwaitingReset` joins only its own +gesture's reset job), is not reachable by human input, and fixing it would diverge from the +mirrored upstream — left as upstream parity. + +### Defect 4: press cancelled when the list shifts under the pointer (commit "multiplatform: don't cancel command click when chat list shifts under the pointer") + +Clicks were still lost after defect 3's fixes. `waitForUpOrCancellation` cancels a press when an +event's position is out of the node's bounds — correct for a pointer dragged away, wrong when the +*node* moved out from under a stationary pointer, which is exactly what happens when the sent +command's message inserts and shifts the list during the press. In node-local coordinates the two +cases are numerically identical; reproduced in a headless harness (60px shift under a held +pointer → synthetic `Exit` at out-of-bounds local position → press cancelled). + +**Fix:** the two cases separate in window coordinates. `ClickableText` tracks its +`LayoutCoordinates` (`onGloballyPositioned`) and passes a window-position lambda into +`detectGesture`; `waitForUpOrCancellation` exempts the out-of-bounds cancel when the pointer moved +≤ `touchSlop` in window space since the down. Real drag-aways (> slop) cancel as before; both +parameters default to null, reducing the check literally to the old expression for other callers. + +### Defect 5: every message insertion reset all pointer handlers (commit "multiplatform: stop pointer handler resets on chat item recomposition…") + +The dominant residual mechanism, found by instrumenting handler lifecycle: every insertion +produced a burst of gesture-handler cancellations for all visible clickable messages — with the +composables *not* disposed and the handler restarting on the *same* node. A press in flight during +such a burst died silently (`down` with no release/cancel), needing a second click; the hover +handler reset the same way, causing the residual hand-cursor flicker. + +Cause: `ChatViewListItem` provided `LocalViewConfiguration.current.bigTouchSlop()` — a **new +anonymous `ViewConfiguration` instance on every recomposition** of every item, and +`SuspendingPointerInputModifierNodeImpl.onViewConfigurationChange()` is +`resetPointerInputHandler()` (verified in the 1.8.2 sources). Insertions recompose all visible +items (shifting `index`), so each insertion reset every pointer handler in the viewport. + +**Fix:** provide a remembered instance — `remember(viewConfiguration) { +viewConfiguration.bigTouchSlop() }` — keyed on the parent `ViewConfiguration` so a real platform +change still regenerates it. Values are unchanged (the wrapper delegates); only the identity that +the reset machinery reacts to is stabilized. Verified in a harness: a press held across a +recomposition dies with the per-recomposition instance and survives with the remembered one. + +## Performance + +- `detectCursorMove` is now cheaper per event than before (no scope teardown/re-entry per event); + the pressed check allocates one list iterator per event. +- Up to two added recompositions per exit/re-enter cycle of a Hand region (the exit reset writes + `icon.value` Hand→Text, making the next re-enter a Text→Hand write where pre-fix both were + no-ops); otherwise the icon state logic is unchanged. Android is a no-op. +- The defect 3 fix launches two extra short-lived coroutines per gesture (reset job, release/cancel + job) — negligible at click rate. Not restarting `pointerInput` on every recomposition removes + per-recomposition coroutine churn that existed before. + +Framework line numbers cited above are from the official `ui-desktop-1.8.2`/`foundation-desktop-1.8.2` +sources jars on Maven Central; they will drift on upgrade. + +## Testing + +Verified on Linux (AppImage), in two stages (commit subjects name the defect layer each commit +fixes; the user-visible symptom needed both layers, per stage 1 below): + +1. A build with only the detection-layer fix (lossless `detectCursorMove`) was tested first and + the stale-cursor symptom **still reproduced** — this is the empirical justification for the + imperative display-layer workaround; the declarative-only fix is not sufficient. +2. With both layers (pre-hardening build, commit `df6b0655d`): hovering commands after clicking + several in a row, clicking stacked `/join` commands without moving the mouse, and sweeping + across commands quickly — the hand cursor tracks correctly in all cases. + +The later hardening commits (release refresh, pressed/bounds gating, exit resets, canvas cache) +are verified by compilation on both targets and multi-pass adversarial review; runtime +re-verification of the final build is pending. + +Defect 3 is verified by the two isolated repro harnesses described above (both mechanisms +reproduced pre-fix, both register every click post-fix — including a click held across a +recomposition and two fast clicks back-to-back through real Compose event dispatch in +`ImageComposeScene`), by compilation, and by multi-pass adversarial review (two clean passes +pre-commit, two post-commit). + +Stage 1's conclusion is retroactively invalidated by defect 5 (see defect 2's removal note): the +"detection fix alone is insufficient" evidence was gathered while insertions were resetting the +detection handler itself. After defect 5's fix, the imperative workaround was removed and the +hover scenarios re-verified passing on the framework path alone. + +Defects 4 and 5 were isolated with four rounds of temporary instrumentation in the real app +(logging every gesture exit path, composable disposal, and handler/node identity — removed before +commit), each mechanism reproduced and its fix verified in a headless harness, reviewed through +two clean adversarial passes, and confirmed fixed by runtime testing on Linux: fast successive +command clicks all register, and the residual hand-cursor flicker on message insertion is gone. +Known unfixable-at-this-layer residue: a click that lands where a command *was* before the list +shifted (pre-press shift — an aiming race, not an event-handling defect). diff --git a/plans/2026-07-14-fix-draft-message-loss-on-restricted.md b/plans/2026-07-14-fix-draft-message-loss-on-restricted.md new file mode 100644 index 0000000000..f001e38f73 --- /dev/null +++ b/plans/2026-07-14-fix-draft-message-loss-on-restricted.md @@ -0,0 +1,67 @@ +# Fix draft loss when switching to a chat where user cannot send messages + +**Branch:** `nd/fix-draft-message-loss-on-restricted` · **PR:** [#7239](https://github.com/simplex-chat/simplex-chat/pull/7239) (draft) · **Commit:** `9d4bb2188` + +## Problem + +A draft typed in the current chat is lost when another chat is opened where the user +cannot send messages (observer, channel subscriber, review by admins). Reproduces on +desktop only: the chat list is always visible, so `chat` and `chatId` change in the +same recomposition (`CenterPartOfScreen` passes `ChatModel.chatId` itself as +`currentChatId`, App.kt:389). Android is unaffected — `AndroidScreen` updates +`currentChatId` via a `snapshotFlow` collector, so the draft-saving effect fires a +frame before the chat swaps. + +## Root cause + +In `ComposeView.kt`, the effect clearing compose state of a non-sendable chat +(`LaunchedEffect(sendMsgEnabled)`) was composed *before* the draft-saving +`KeyChangeEffect(chatModel.chatId)`. `LaunchedEffect` bodies run in composition +order, so on a same-recomposition switch the clear effect wiped the live compose +state before it could be saved, and `KeyChangeEffect` then fell into its +else-branch, destroying any previously saved draft via `clearPrevDraft` and deleting +attachment files. Present since the effect was introduced (`7b362ff65`, #5922) — +not a recent regression. + +## Fix + +Move the clearing effect after `KeyChangeEffect` (6 lines moved + 2-line comment). +The draft is saved first; `clearCurrentDraft()` is then a no-op for it because +`draftChatId` no longer matches the opened chat. Preserved behavior: the opened +chat's own draft is still cleared when it cannot send, and the draft is still +cleared when the open chat itself becomes non-sendable (only the `sendMsgEnabled` +key changes). Incidental improvements: an active live message is now sent on switch +instead of dropped, and the `inProgress` branch no longer resurrects cleared text. + +## Verification + +- Headless Compose-runtime harness (scratchpad `TmpDraftLossEffectOrderTest.kt` / + `TmpDraftFixVerifyTest.kt`): reproduced the bug (clear-before-save order observed), + verified the fix across 5 scenarios (switch to non-sendable, in-chat demotion, + non-sendable chat's own saved draft cleared, Android staggered path, sendable + control). All pass; `:common:desktopTest` and `:common:compileKotlinDesktop` green. +- Manual desktop repro confirmed the bug and fix. +- Works with `privacySaveLastDraft` off: text discarded per the setting's contract, + never saved; disabling purges the slot, so no stale-state interactions. + +## iOS + +Not reproduced on device (desktop repro path is safe on iOS: back-navigation fires +`ComposeView.onDisappear`, which saves the draft). A code-traced fix for in-place +chat switches (member info → "message", notification tap, "forwarded from" — where +`onDisappear` never runs) was removed from the PR and parked on +`nd/ios-draft-save-on-chat-switch` (commit `7b9b8d987`) pending device verification. +Decisive test: type a draft in a group → member info → "message" → back to group. +If the draft survives without the change, delete the side branch. + +## Pre-existing issues found (not addressed, both platforms) + +- Secondary (member support/reports) chat views share the group's chat id and race + the primary on the single draft slot (Kotlin: unguarded `KeyChangeEffect` in + secondary instances can clear the primary's just-saved draft when a modal is open + during a switch; iOS: secondary `onDisappear` saves under the swapped chat id). +- Kotlin: drafts are not saved when a chat is closed to `chatId = null` on desktop + (ChatView is disposed before `KeyChangeEffect` runs; only the forwarding case is + saved via `DisposableEffect`). +- Switching between two non-sendable chats restores the target's draft into the + disabled composer (clear effect does not re-fire on `false→false`). diff --git a/plans/2026-07-21-fix-video-preview-black-area.md b/plans/2026-07-21-fix-video-preview-black-area.md new file mode 100644 index 0000000000..b5643ecb6b --- /dev/null +++ b/plans/2026-07-21-fix-video-preview-black-area.md @@ -0,0 +1,70 @@ +# Fix: video preview expands into a long empty black area + +## Problem + +On Android, once a video message finishes downloading, its in-chat preview grows +into a tall empty black area below the thumbnail. It appears the moment the file +download completes (before playback), and is worst for landscape videos. + +## Root cause + +The video message renders into the shared `CHAT_IMAGE_LAYOUT_ID` box measured by +`PriorityLayout` (`FramedItemView.kt`). Unlike `CIImageView`, `CIVideoView` never +gave that box a definite size — it inherited the height of its tallest child. + +Once downloaded, the tallest child is the player surface. On Android that is a +`StyledPlayerView` with `RESIZE_MODE_FIXED_WIDTH`. Before playback the player is +unprepared (the media source is only set in `VideoPlayer.start()`, called from +`play()`), so it reports no video size and its `AspectRatioFrameLayout` keeps +aspect ratio 0 and does **not** shrink its height. + +This was latent until #6726 ("constrain image sizes for previews"). That change +bounded the box to `maxHeight = 2.33 × width` to tame extreme image aspect +ratios. Before it, the box was measured with unbounded height, so on Android the +unprepared surface collapsed to 0 (`getDefaultSize` with an `UNSPECIFIED` spec) +and contributed no height — accidentally masking the missing-size flaw. After +#6726 the surface expands to fill the now-bounded `2.33 × width`, showing as a +long black strip behind the shorter thumbnail. + +The later image fixes (#7123 / #7125 / #7223) all touched `CIImageView` only, so +video was never given a definite box size and kept the defect. + +## Fix + +Give `CIVideoView`'s `CHAT_IMAGE_LAYOUT_ID` box a definite size derived from the +preview aspect ratio, mirroring `CIImageView`. Height is computed from the +*measured* (clamped) width via `Modifier.width(w).layout { … }`, not the nominal +width, so a wide video whose nominal `w = DEFAULT_MAX_IMAGE_WIDTH` (500dp) is +clamped on a narrower screen does not leave an empty strip below — the same +correction #7223 applied to images. Applied only for `!smallView`; the small +chat-list thumbnail (`smallView = true`) keeps its existing sizing. + +With the box sized to the preview footprint, the player surface can no longer +expand past it (its bounded max height now equals the thumbnail height), and the +opaque preview drawn on top exactly covers it. When playing, `RESIZE_MODE_FIXED_WIDTH` +conforms the surface to the real video aspect, which matches the thumbnail. + +## Alternatives considered + +- **Force the Android `StyledPlayerView` aspect from the preview.** Android-only; + treats one render backend and leaves the box height still child-dependent — a + workaround at the wrong layer. +- **Only mount `PlayerView` while playing.** Larger behavioural change; risks the + play/preview transition blinking the code already guards against; does not + establish the container invariant. +- **`matchParentSize()` on the player.** Breaks: when playing, the preview is + removed, so no sibling defines the box and it collapses to 0. +- **Naive `Modifier.width(w).height(w * ratio)`.** Reintroduces the exact #7223 + empty-strip bug for wide videos on screens narrower than 500dp. + +The chosen fix restores the invariant "a media chat item occupies exactly the +media's aspect-ratio footprint" at the correct layer (the media container), for +all render states (pre-download / downloaded-idle / playing / encrypted) and both +platforms, using the same aspect source the player conforms to. + +## Verification + +- `android:assembleDebug` (arm64 debug APK) — BUILD SUCCESSFUL. +- Desktop `createDistributable` + AppImage (Linux x86_64) — BUILD SUCCESSFUL. +- Control APK built from the same base without the fix, to A/B the black area + against the fixed build (both `7.0-beta.4`, differing only by this change). diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f410089bff..6e82981f48 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.0.0.9 +version: 7.0.0.10 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 6ba49e223c..eaf91e58e5 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -156,7 +156,7 @@ createActiveUser cc CoreChatOpts {chatRelay, headless} createBot_ userDisplayNam \It is only stored on your device and you can change it later." where loop = do - displayName <- T.pack <$> withPrompt "display name" getLine + displayName <- T.pack <$> withPrompt "display name: " getLine createUser loop False $ mkProfile displayName mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} createUser onError clientService p = diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6c24f37111..7fa3aaa96b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -441,7 +441,6 @@ processChatCommand cxt nm = \case where createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () createPresetContactCards db user = do - createContact db cxt user simplexStatusContactProfile createContact db cxt user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) chooseServers user_ = do @@ -693,7 +692,8 @@ processChatCommand cxt nm = \case (SCTGroup, SMDSnd) -> L.nonEmpty <$> withFastStore' (`getGroupSndStatuses` itemId) _ -> pure Nothing forwardedFromChatItem <- getForwardedFromItem user ci - pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem} + fileXftpServers <- getChatItemFileServers user dir ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} where getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of @@ -2889,14 +2889,14 @@ processChatCommand cxt nm = \case -- TODO [relays] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" - let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, anyRosterChange, finalPrivilegedCount) = selectMembers members + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, anyRelay, anyRosterChange, finalPrivilegedCount) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ throwCmdError "can't change role of multiple members when admins selected, or new role is admin" when anyPending $ throwCmdError "can't change role of members pending approval" - -- TODO allow moderators (recipients already accept it; needs UI too): the observer..member limit is an - -- TODO `all` over targets - maxRole can't express it, a max hides targets below GRObserver (relay, - -- TODO unknown) that receivers reject. Fold roleRequiredToChange per target, or add allModeratable. + when (anyRelay || newRole == GRRelay) $ throwCmdError "relay role can't be changed" + -- TODO allow moderators (needs UI) - relay is rejected above (anyRelay), so drop the GRAdmin floor: + -- TODO assertUserGroupRole gInfo (roleRequiredToChange maxRole newRole) assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) -- in relay groups the roster has a single signer, so only the owner may change member/moderator/admin roles when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $ @@ -2917,22 +2917,23 @@ processChatCommand cxt nm = \case -- anyPrivilegedTarget: a target currently member/moderator/admin (gates the owner-only check); anyRosterChange: -- a current member's role change that alters the roster blob - the only case that bumps the version, since a -- bump with no delta reads as a gap to subscribers; finalPrivilegedCount: moderators + admins after the change. - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool, Bool, Bool, Int) - selectMembers = foldr' addMember ([], [], [], GRObserver, False, False, False, False, 0) + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool, Bool, Bool, Bool, Int) + selectMembers = foldr' addMember ([], [], [], GRObserver, False, False, False, False, False, 0) where - addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, anyRosterChange, privCount) + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, anyRelay, anyRosterChange, privCount) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin anyPending' = anyPending || memberPending m anyPrivTarget' = anyPrivTarget || isRosterRole memberRole + anyRelay' = anyRelay || memberRole == GRRelay privCount' = if isRosterRole newRole then privCount + 1 else privCount in if - | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRosterChange, privCount') - | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRosterChange, privCount') + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRelay', anyRosterChange, privCount') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRelay', anyRosterChange, privCount') -- a current member's role actually changes here; it alters the roster iff the old or new role is on it - | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRosterChange || isRosterRole newRole || isRosterRole memberRole, privCount') - | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, anyRosterChange, if isRosterRole memberRole then privCount + 1 else privCount) + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', anyRelay', anyRosterChange || isRosterRole newRole || isRosterRole memberRole, privCount') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, anyRelay, anyRosterChange, if isRosterRole memberRole then privCount + 1 else privCount) changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) changeRoleInvitedMems user gInfo memsToChange = do -- not batched, as we need to send different invitations to different connections anyway diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1252dabba8..90136e35b8 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -343,7 +343,7 @@ prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo - prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent | not supportAllowed = Just GFSupport | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice - | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles + | isNothing scopeInfo && not (isVoice mc) && (isJust file_ || isMedia mc) && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports | isNothing scopeInfo && prohibitedSimplexLinks gInfo m mc ft = Just GFSimplexLinks | otherwise = Nothing @@ -764,7 +764,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} if userApprovedRelays then receive' rd True else do - let srvs = fileServers rd + let srvs = fileDescrServers rd unknownSrvs <- getUnknownSrvs srvs let approved = null unknownSrvs ifM @@ -777,9 +777,6 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs approved startReceivingFile user fileId withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) - fileServers :: ValidFileDescription 'FRecipient -> [XFTPServer] - fileServers (FD.ValidFileDescription FD.FileDescription {chunks}) = - S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user @@ -1291,13 +1288,11 @@ isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin isPrivilegedRole :: GroupMemberRole -> Bool isPrivilegedRole r = r >= GRMember --- Minimum role allowed to change a member's role from `from` to `to` (moderators only within observer..member). +-- Minimum role allowed to change a member's role from `from` to `to` (moderators only up to member; relay checked separately). roleRequiredToChange :: GroupMemberRole -> GroupMemberRole -> GroupMemberRole roleRequiredToChange from to - | moderatable from && moderatable to = GRModerator + | from <= GRMember && to <= GRMember = GRModerator | otherwise = maximum ([GRAdmin, from, to] :: [GroupMemberRole]) - where - moderatable r = GRObserver <= r && r <= GRMember -- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. -- Runs on the parsed roster blob. @@ -1745,6 +1740,30 @@ parseFileDescription :: FilePartyI p => Text -> CM (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) +-- | Unique XFTP servers hosting the file's chunks, parsed from a stored file description. +fileDescrServers :: ValidFileDescription p -> [XFTPServer] +fileDescrServers (FD.ValidFileDescription FD.FileDescription {chunks}) = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + +-- | XFTP servers the file's data chunks were uploaded to (sender's servers for sent items, +-- the same servers via the recipient description for received items). +-- Returns [] for non-XFTP/inline files or when no description is available; never fails the caller. +getChatItemFileServers :: User -> SMsgDirection d -> ChatItem c d -> CM [XFTPServer] +getChatItemFileServers user dir ci = case ci of + ChatItem {file = Just CIFile {fileId, fileProtocol = FPXFTP}} -> + itemFileServers fileId `catchAllErrors` \_ -> pure [] + _ -> pure [] + where + itemFileServers fileId = case dir of + SMDSnd -> do + sfd_ <- withStore' $ \db -> getSndFTPrivateSndDescr db user fileId + case sfd_ of + Just sfdText -> fileDescrServers <$> (parseFileDescription sfdText :: CM (ValidFileDescription 'FSender)) + Nothing -> pure [] + SMDRcv -> do + RcvFileDescr {fileDescrText} <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + fileDescrServers <$> (parseFileDescription fileDescrText :: CM (ValidFileDescription 'FRecipient)) + sendDirectFileInline :: User -> Contact -> FileTransferMeta -> SharedMsgId -> CM () sendDirectFileInline user ct ft sharedMsgId = do msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage user ct @@ -3224,21 +3243,6 @@ simplexTeamContactProfile = contactDomain = Nothing } -simplexStatusContactProfile :: Profile -simplexStatusContactProfile = - Profile - { displayName = "SimpleX Status", - fullName = "", - shortDescr = Just "Automatic server status and app release updates", - description = Nothing, - image = Just (ImageData "data:image/jpg;base64,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"), - contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), - peerType = Just CPTBot, - preferences = Nothing, - badge = Nothing, - contactDomain = Nothing - } - timeItToView :: String -> CM' a -> CM' a timeItToView s action = do t1 <- liftIO getCurrentTime diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index cf34559a26..b031c46507 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3337,6 +3337,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = xGrpMemRole :: GroupInfo -> Maybe GroupMember -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemRole gInfo@GroupInfo {membership} fwdRelay_ m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs + | memRole == GRRelay = messageError "x.grp.mem.role: relay role can't be assigned" $> Nothing | membershipMemId == memId = applyAtRosterVersion gInfo fwdRelay_ m rosterVer_ $ let gInfo' = gInfo {membership = membership {memberRole = memRole}} @@ -3363,6 +3364,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member); -- the delivery scope (relay forwarding) is computed on the pre-change role changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent createItem + | fromRole == GRRelay = + messageError "x.grp.mem.role: relay role can't be changed" $> Nothing | senderRole < roleRequiredToChange fromRole memRole = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = @@ -3873,6 +3876,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just (author, unknown) | memberRemoved author -> logInfo $ "x.grp.msg.forward: ignoring content from removed member, group " <> tshow (groupId' gInfo) <> ", member " <> safeDecodeUtf8 (strEncode memberId) <> ", event " <> tshow (toCMEventTag chatMsgEvent) + | not (useRelays' gInfo) && not (expectedForwarder author) -> + logInfo $ "x.grp.msg.forward: ignoring content from unexpected forwarder, group " <> tshow (groupId' gInfo) <> ", forwarder " <> tshow (groupMemberId' m) <> ", member " <> safeDecodeUtf8 (strEncode memberId) <> ", event " <> tshow (toCMEventTag chatMsgEvent) | otherwise -> do when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $ @@ -3880,6 +3885,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Nothing -> pure () FwdChannel -> processForwardedMsg (VMUnsigned chatMsg) Nothing where + -- Forwards are only expected from the member that introduced us to the author: our host, or + -- the author's inviter. Unknown members have no such record, so any admin may forward theirs. + expectedForwarder :: GroupMember -> Bool + expectedForwarder author = + memberCategory m == GCHostMember + || invitedByGroupMemberId author == Just (groupMemberId' m) + || memberStatus author == GSMemUnknown -- ! see isForwardedGroupMsg: forwarded group events should include msgId to be deduplicated processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM () processForwardedMsg verifiedMsg author_ = do diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b4709fb5c5..836fb004fe 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -55,7 +55,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -1345,7 +1345,8 @@ instance TextEncoding CIForwardedFromTag where data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), - forwardedFromChatItem :: Maybe AChatItem + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] } deriving (Show) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2863b20b4b..19ba3d022d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -815,6 +815,13 @@ isVoice = \case MCVoice {} -> True _ -> False +isMedia :: MsgContent -> Bool +isMedia = \case + MCImage {} -> True + MCVideo {} -> True + MCFile {} -> True + _ -> False + isReport :: MsgContent -> Bool isReport = \case MCReport {} -> True diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 762583b7a5..5d862ea258 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -20,6 +20,7 @@ module Simplex.Chat.Store.Files createSndFileTransferXFTP, createSndFTDescrXFTP, setSndFTPrivateSndDescr, + getSndFTPrivateSndDescr, updateSndFTDescrXFTP, createExtraSndFTDescrs, updateSndFTDeliveryXFTP, @@ -210,6 +211,15 @@ setSndFTPrivateSndDescr db User {userId} fileId sfdText = do "UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (sfdText, currentTs, userId, fileId) +getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text) +getSndFTPrivateSndDescr db User {userId} fileId = + fmap (maybe Nothing fromOnly) $ + maybeFirstRow id $ + DB.query + db + "SELECT private_snd_file_descr FROM files WHERE user_id = ? AND file_id = ?" + (userId, fileId) + updateSndFTDescrXFTP :: DB.Connection -> User -> SndFileTransfer -> Text -> IO () updateSndFTDescrXFTP db user@User {userId} sft@SndFileTransfer {fileId, fileDescrId} rfdText = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5d6cc8fd8b..7b71e61512 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2093,6 +2093,7 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId} = do [sql| UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) @@ -2149,6 +2150,7 @@ getGroupUnreadTimedItems db User {userId} groupId scope = SELECT chat_item_id, timed_ttl FROM chat_items WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL |] (userId, groupId, CISRcvNew) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 36f32944e2..14e2299514 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1586,10 +1586,11 @@ Query: SELECT chat_item_id, timed_ttl FROM chat_items WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) Query: SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at @@ -5042,10 +5043,11 @@ SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) Query: UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0d49e075b9..15f713cd1c 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1760,6 +1760,7 @@ withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup webFolder test = do withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser + enableNamesRole ds when createOwnersGroup $ do superUser ##> "/g owners" superUser <## "group #owners is created" @@ -2131,6 +2132,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do + enableNamesRole bob bob `connectVia` dsLink (shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay registerName reg newsName (channelNameRecord "news" (T.pack shortLink)) @@ -2171,6 +2173,7 @@ testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do + enableNamesRole bob bob `connectVia` dsLink (shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay registerName reg newsName (channelNameRecord "news" (T.pack shortLink)) diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 232d97d8dd..e34f071fd0 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1920,14 +1920,14 @@ testMultipleUserAddresses = cLinkAlisa <- getContactLink alice True bob ##> ("/c " <> cLinkAlisa) alice <#? bob - alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Audio/video calls: enabled"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) + alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Audio/video calls: enabled"), ("@Ask SimpleX Team", ""), ("*", "")]) alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" concurrently_ (bob <## "alisa: contact is connected") (alice <## "bob (Bob): contact is connected") threadDelay 100000 - alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", lastChatFeature), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) + alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", lastChatFeature), ("@Ask SimpleX Team", ""), ("*", "")]) alice <##> bob bob #> "@alice hey alice" @@ -1958,7 +1958,7 @@ testMultipleUserAddresses = (cath <## "alisa: contact is connected") (alice <## "cath (Catherine): contact is connected") threadDelay 100000 - alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", lastChatFeature), ("@bob", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) + alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", lastChatFeature), ("@bob", "hey"), ("@Ask SimpleX Team", ""), ("*", "")]) alice <##> cath -- first user doesn't have cath as contact @@ -2171,7 +2171,7 @@ testUsersDifferentCIExpirationTTL ps = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 3000000 @@ -2184,11 +2184,11 @@ testUsersDifferentCIExpirationTTL ps = do -- second user messages alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 2100000 - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2254,7 +2254,7 @@ testUsersRestartCIExpiration ps = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 3000000 @@ -2267,11 +2267,11 @@ testUsersRestartCIExpiration ps = do -- second user messages alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 4000000 - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2313,7 +2313,7 @@ testEnableCIExpirationOnlyForOneUser ps = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 2000000 @@ -2325,14 +2325,14 @@ testEnableCIExpirationOnlyForOneUser ps = do -- messages are not deleted for second user alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "subscribed 1 connections on server localhost" alice <## "subscribed 1 connections on server localhost" -- messages are not deleted for second user after restart - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) alice #> "@bob alisa 5" bob <# "alisa> alisa 5" @@ -2342,7 +2342,7 @@ testEnableCIExpirationOnlyForOneUser ps = do threadDelay 2000000 -- new messages are not deleted for second user - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4"), (1, "alisa 5"), (0, "alisa 6")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4"), (1, "alisa 5"), (0, "alisa 6")]) where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2376,12 +2376,12 @@ testDisableCIExpirationOnlyForOneUser ps = do bob #> "@alisa alisa 2" alice <# "bob> alisa 2" - alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2")]) + alice #$> ("/_get chat @5 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2")]) threadDelay 2000000 -- second user messages are deleted - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "subscribed 1 connections on server localhost" @@ -2395,12 +2395,12 @@ testDisableCIExpirationOnlyForOneUser ps = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 3000000 -- second user messages are deleted - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} @@ -2415,7 +2415,7 @@ testUsersTimedMessages ps' = do alice ##> "/create user alisa" showActiveUser alice "alisa" connectUsers alice bob - configureTimedMessages alice bob "6" "3" + configureTimedMessages alice bob "5" "3" -- first user messages alice ##> "/user alice" @@ -2444,7 +2444,7 @@ testUsersTimedMessages ps' = do alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner"), (1, "alisa 1"), (0, "alisa 2")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 1"), (0, "alisa 2")]) threadDelay 1000000 @@ -2457,7 +2457,7 @@ testUsersTimedMessages ps' = do alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner"), (1, "alisa 1"), (0, "alisa 2")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 1"), (0, "alisa 2")]) threadDelay 1000000 @@ -2466,7 +2466,7 @@ testUsersTimedMessages ps' = do alice ##> "/user" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) -- first user messages alice ##> "/user alice" @@ -2496,7 +2496,7 @@ testUsersTimedMessages ps' = do alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) -- messages are deleted after restart threadDelay 1000000 @@ -2510,7 +2510,7 @@ testUsersTimedMessages ps' = do alice ##> "/user alisa" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner"), (1, "alisa 3"), (0, "alisa 4")]) threadDelay 1000000 @@ -2519,7 +2519,7 @@ testUsersTimedMessages ps' = do alice ##> "/user" showActiveUser alice "alisa" - alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) + alice #$> ("/_get chat @5 count=100", chat, [(1,"chat banner")]) where ps = ps' {printOutput = True} :: TestParams configureTimedMessages :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 8154dd048b..1e72e6889f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -240,6 +240,7 @@ chatGroupTests = do it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen + it "should not read support chat items when reading group without scope" testScopedSupportUnreadStatsGroupReadNoScope it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves @@ -8464,6 +8465,43 @@ testScopedSupportUnreadStatsCorrectOnOpen = { markRead = False } +testScopedSupportUnreadStatsGroupReadNoScope :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsGroupReadNoScope = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + -- capture the support item id directly: lastItemId returns the latest item by + -- item_ts, which right after createGroup2 can be a group event ("connected") + -- rather than the support message, making the per-item read below target the + -- wrong item. + bobItemId <- + withCCTransaction alice $ \db -> do + rows <- DB.query_ db "SELECT chat_item_id FROM chat_items WHERE group_scope_tag = 'member_support' ORDER BY chat_item_id DESC LIMIT 1" :: IO [Only Int] + case rows of + Only iId : _ -> pure $ show iId + _ -> error "testScopedSupportUnreadStatsGroupReadNoScope: no member_support item" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" + + -- reading the group without scope must not mark support scope items read + alice #$> ("/_read chat #1", id, "ok") + + -- the support item was left unread, so reading it in scope still decrements the stats + alice #$> ("/_read chat items #1(_support:2) " <> bobItemId, id, "items read for chat") + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + where + opts = + testOpts + { markRead = False + } + testScopedSupportMemberRemoved :: HasCallStack => TestParams -> IO () testScopedSupportMemberRemoved = testChatOpts3 opts aliceProfile bobProfile cathProfile $ \alice bob cath -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index ff831473cd..caa09b9d33 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -3960,14 +3960,14 @@ testShortLinkChangePreparedContactUser = testChat2 aliceProfile bobProfile test bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" - -- 2 ids are for "user contacts", 2 ids are for second user contact cards, so alice is id 5 - bob ##> "/_set contact user @5 2" + -- 2 ids are for "user contacts", 1 id is for second user contact card, so alice is id 4 + bob ##> "/_set contact user @4 2" bob <## "contact alice changed from user bob to user robert" bob ##> "/user robert" showActiveUser bob "robert" - bob ##> "/_connect contact @5 text hello" + bob ##> "/_connect contact @4 text hello" bob <### [ "alice: connection started", WithTime "@alice hello" @@ -3981,8 +3981,8 @@ testShortLinkChangePreparedContactUser = testChat2 aliceProfile bobProfile test alice @@@ [("@robert", "hey")] alice `hasContactProfiles` ["alice", "robert"] - bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) - bob `hasContactProfiles` ["robert", "alice", "Ask SimpleX Team", "SimpleX Status"] + bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@Ask SimpleX Team", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "Ask SimpleX Team"] bob ##> "/user bob" showActiveUser bob "bob (Bob)" bob @@@ [] @@ -4010,16 +4010,16 @@ testShortLinkChangePreparedContactUserDuplicate = testChat2 aliceProfile bobProf bob <## "alice: contact is prepared" -- 2 ids are for "user contacts" - -- 2 ids are for second user contact cards + -- 1 id is for second user contact card -- 1 for second user's alice - -- so this alice is id 6 - bob ##> "/_set contact user @6 2" + -- so this alice is id 5 + bob ##> "/_set contact user @5 2" bob <## "contact alice changed from user bob to user robert, new local name: alice_1" bob ##> "/user robert" showActiveUser bob "robert" - bob ##> "/_connect contact @6 text hello" + bob ##> "/_connect contact @5 text hello" bob <### [ "alice_1: connection started", WithTime "@alice_1 hello" @@ -4038,8 +4038,8 @@ testShortLinkChangePreparedContactUserDuplicate = testChat2 aliceProfile bobProf alice @@@ [("@robert", "hey"), ("@robert_1", "hey")] alice `hasContactProfiles` ["alice", "robert", "robert"] - bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) - bob `hasContactProfiles` ["robert", "alice", "alice", "Ask SimpleX Team", "SimpleX Status"] + bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@Ask SimpleX Team", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "alice", "Ask SimpleX Team"] bob ##> "/user bob" showActiveUser bob "bob (Bob)" bob @@@ [] @@ -4132,8 +4132,8 @@ testShortLinkChangePreparedGroupUser = testChat3 aliceProfile bobProfile cathPro alice @@@ [("#team", "3"), ("@cath","sent invitation to join group team as admin")] alice `hasContactProfiles` ["alice", "cath", "robert"] - bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) - bob `hasContactProfiles` ["robert", "alice", "cath", "Ask SimpleX Team", "SimpleX Status"] + bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@Ask SimpleX Team", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "cath", "Ask SimpleX Team"] cath @@@ [("#team", "3"), ("@alice","received invitation to join group team as admin")] cath `hasContactProfiles` ["cath", "alice", "robert"] bob ##> "/user bob" @@ -4246,7 +4246,7 @@ testShortLinkChangePreparedGroupUserDuplicate = testChat3 aliceProfile bobProfil alice @@@ [("#team", "7"), ("@cath","sent invitation to join group team as admin")] alice `hasContactProfiles` ["alice", "cath", "robert", "robert"] - bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "Ask SimpleX Team", "SimpleX Status"] + bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "Ask SimpleX Team"] cath @@@ [("#team", "7"), ("@alice","received invitation to join group team as admin")] cath `hasContactProfiles` ["cath", "alice", "robert", "robert"] bob ##> "/user bob"