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/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 238ea89e90..27840a07c2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -638,6 +638,7 @@ struct ChatListSearchBar: View { @Binding var searchShowingSimplexLink: Bool @Binding var searchChatFilteredBySimplexLink: Set @Binding var parentSheet: SomeSheet? + @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @State private var ignoreSearchTextChange = false // when the search text is a SimpleX name, the string to connect to (with @/# preserved); nil otherwise @State private var connectNameCandidate: String? = nil @@ -645,8 +646,9 @@ struct ChatListSearchBar: View { var body: some View { VStack(spacing: 12) { - // a typed name replaces the list tags with a row to connect to it (as on Android mobile) - if let candidate = connectNameCandidate { + // a typed name shows a row to connect to it (as on Android mobile): with the reachable toolbar it + // replaces the tags above the search field; in top bar mode the tags stay and it moves below (end of VStack) + if oneHandUI, let candidate = connectNameCandidate { connectByNameRow(candidate) } else { ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } @@ -685,6 +687,9 @@ struct ChatListSearchBar: View { toggleFilterButton() } } + if !oneHandUI, let candidate = connectNameCandidate { + connectByNameRow(candidate) + } } .onChange(of: searchFocussed) { sf in withAnimation { searchMode = sf } diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index b61b81a46b..425f6e022f 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -184,7 +184,11 @@ struct OnboardingConditionsView: View { private func completeOnboarding() { let m = ChatModel.shared onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete + // dismiss any presented onboarding sheet and defer the swap, so the deep onboarding + // navigation stack isn't torn down mid-transition (crashes UIKit on completion) + dismissAllSheets(animated: false) { + m.onboardingStage = .onboardingComplete + } } private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 2768318c78..d891efcd90 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -135,20 +135,6 @@ struct PrivacySettings: View { } } } - .onChange(of: contactReceipts) { _ in - if contactReceiptsReset { - contactReceiptsReset = false - } else { - setOrAskSendReceiptsContacts(contactReceipts) - } - } - .onChange(of: groupReceipts) { _ in - if groupReceiptsReset { - groupReceiptsReset = false - } else { - setOrAskSendReceiptsGroups(groupReceipts) - } - } .onChange(of: autoAcceptMemberContacts) { _ in if autoAcceptMemberContactsReset { autoAcceptMemberContactsReset = false @@ -158,14 +144,6 @@ struct PrivacySettings: View { } .onAppear { if let u = m.currentUser { - if contactReceipts != u.sendRcptsContacts { - contactReceiptsReset = true - contactReceipts = u.sendRcptsContacts - } - if groupReceipts != u.sendRcptsSmallGroups { - groupReceiptsReset = true - groupReceipts = u.sendRcptsSmallGroups - } if autoAcceptMemberContacts != u.autoAcceptMemberContacts { autoAcceptMemberContactsReset = true autoAcceptMemberContacts = u.autoAcceptMemberContacts @@ -312,6 +290,32 @@ struct PrivacySettings: View { } } } + .onChange(of: contactReceipts) { _ in + if contactReceiptsReset { + contactReceiptsReset = false + } else { + setOrAskSendReceiptsContacts(contactReceipts) + } + } + .onChange(of: groupReceipts) { _ in + if groupReceiptsReset { + groupReceiptsReset = false + } else { + setOrAskSendReceiptsGroups(groupReceipts) + } + } + .onAppear { + if let u = m.currentUser { + if contactReceipts != u.sendRcptsContacts { + contactReceiptsReset = true + contactReceipts = u.sendRcptsContacts + } + if groupReceipts != u.sendRcptsSmallGroups { + groupReceiptsReset = true + groupReceipts = u.sendRcptsSmallGroups + } + } + } .navigationTitle("More privacy") .modifier(ThemedBackground(grouped: true)) } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index cc8584b46d..b9048becda 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -875,7 +875,10 @@ struct SetSimplexDomainView: View { Button { openBrowserAlert(uri: "https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md") } label: { - Text("Register a test name") + HStack { + Text("How to register a test name") + Image(systemName: "arrow.up.right.circle") + } } Button { if let w = broadcastWarning, changed { diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index e00a78b98e..b61f14d8a7 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -6978,8 +6978,8 @@ Enable in *Network & servers* settings. Register No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 1f7d2bb1cf..40a0fb3d43 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -6767,8 +6767,8 @@ Enable in *Network & servers* settings. Register No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 1c97ce5786..ee79d08040 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -7466,8 +7466,8 @@ Aktivieren Sie es in den *Netzwerk und Server* Einstellungen. Registrieren No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 4485fadabb..83d57d174b 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -7482,9 +7482,9 @@ Enable in *Network & servers* settings. Register No comment provided by engineer. - - Register a test name - Register a test name + + How to register a test name + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 7c72f354a1..c0c332a6c5 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -7466,8 +7466,8 @@ Actívalo en ajustes de *Servidores y Redes*. Registrar No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index d948ce8885..ad42ac79bd 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -6647,8 +6647,8 @@ Enable in *Network & servers* settings. Register No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 4f75801241..89edaf4a96 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -7434,8 +7434,8 @@ Activez-le dans les paramètres *Réseau et serveurs*. Inscrire No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index f3c407bc23..d81704ce9a 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -7466,8 +7466,8 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Regisztrálás No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 764ad3f00e..74820021c1 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -7466,8 +7466,8 @@ Attivalo nelle impostazioni *Rete e server*. Registra No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 6408b16117..a652af5320 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -6767,8 +6767,8 @@ Enable in *Network & servers* settings. Register No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 933a77bb87..a794ca6f06 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -7221,8 +7221,8 @@ Schakel dit in in *Netwerk en servers*-instellingen. Register No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index fb4622b4c7..d71448c56b 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -7286,8 +7286,8 @@ Włącz w ustawianiach *Sieć i serwery* . Zarejestruj No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index aedb69c876..34e6d4d857 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -7423,8 +7423,8 @@ Enable in *Network & servers* settings. Зарегистрировать No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 5f9a67584d..150562e8a4 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -6625,8 +6625,8 @@ Enable in *Network & servers* settings. Register No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 086638e9ea..a637d84463 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -7282,8 +7282,8 @@ Enable in *Network & servers* settings. Kaydol No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index e04c3b48a3..d3cd4e088e 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -7293,8 +7293,8 @@ Enable in *Network & servers* settings. Зареєструватися No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 799c7f539f..55569611f7 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -7465,8 +7465,8 @@ Enable in *Network & servers* settings. 注册 No comment provided by engineer. - - Register a test name + + How to register a test name No comment provided by engineer. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt index cdbdfc1bad..7ab1ffa33d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -160,9 +160,13 @@ fun SetSimplexDomainView( SectionDividerSpaced() SectionView { if (editing.value) { - SectionItemView({ openBrowserAlert("https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md", uriHandler) }) { - Text(stringResource(MR.strings.register_test_name), color = MaterialTheme.colors.primary) - } + SettingsActionItem( + painterResource(MR.images.ic_open_in_new), + stringResource(MR.strings.register_test_name), + { openBrowserAlert("https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md", uriHandler) }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary + ) SectionItemView({ if (broadcastWarning != null && !unchanged) AlertManager.shared.showAlertDialog(title = broadcastWarning, confirmText = generalGetString(MR.strings.save_verb), onConfirm = { doSave(close) }) else doSave(close) }, disabled = unchanged || saving.value || !isValid) { Text( stringResource(MR.strings.save_verb), 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 617c268b8a..082a33435c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -396,7 +396,7 @@ Save SimpleX name? Get SimpleX name (BETA) Channel SimpleX name - Register a test name + How to register a test name Remove name Save Edit diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index 82365ac1f5..e857ef91b1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -316,21 +316,23 @@ private suspend fun downloadAsset(asset: GitHubAsset) { call.execute().use { response -> response.body?.use { body -> body.byteStream().use { stream -> + val newFile = File(tmpDir, asset.name) // On Windows the install path exits the app before it can delete the downloaded file, so a // previous update's installer can be left in the temp dir; remove it here at the next // download instead of letting it accumulate. Other platforms delete the file after install. - if (desktopPlatform.isWindows()) File(tmpDir, asset.name).delete() - createTmpFileAndDelete { file -> + if (desktopPlatform.isWindows()) newFile.delete() + val partFile = File(tmpDir, "${asset.name}.part") + partFile.parentFile.mkdirs() + partFile.deleteOnExit() + try { // It's important to close output stream (with use{}), otherwise, Windows cannot rename the file - file.outputStream().use { output -> + partFile.outputStream().use { output -> stream.copyTo(output) } - val newFile = File(file.parentFile, asset.name) // Moving instead of renameTo: a bare rename can silently fail (returns false, ignored), - // and the enclosing createTmpFileAndDelete then deletes the only copy in its finally block, // leaving the user with an empty download dir. Files.move performs the same in-place rename // when possible, falls back to copy when it can't, and throws (handled below) on real failure. - Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + Files.move(partFile.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING) AlertManager.shared.showAlertDialogButtonsColumn( generalGetString(MR.strings.app_check_for_updates_download_completed_title), @@ -370,6 +372,8 @@ private suspend fun downloadAsset(asset: GitHubAsset) { } } ) + } finally { + partFile.delete() } } } 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/simplex-directory-service/README.md b/apps/simplex-directory-service/README.md index b64e018adb..5c397b6492 100644 --- a/apps/simplex-directory-service/README.md +++ b/apps/simplex-directory-service/README.md @@ -1,5 +1,309 @@ # SimpleX Directory Service -The service is currently a chat bot that allows to register and search for groups. +Chat bot for registering and searching groups. Superusers and admins are configured via CLI flags. -Superusers are configured via CLI options. +--- + +## Prerequisites + +- GHC 9.6.3 — install via [GHCup](https://www.haskell.org/ghcup/) +- Cabal 3.10.2+ + +--- + +## Building + +```sh +git clone https://github.com/simplex-chat/simplex-chat +cd simplex-chat +# OpenSSL build configuration (required) — copy the file for your OS: +cp scripts/cabal.project.local.linux cabal.project.local # on macOS: scripts/cabal.project.local.mac +cabal build simplex-directory-service +``` + +See [docs/CONTRIBUTING.md](../../docs/CONTRIBUTING.md) for the full build setup (toolchain, OpenSSL headers, branch compatibility). + +Find the compiled binary with: +```sh +cabal list-bin simplex-directory-service +``` + +Or run directly without installing: +```sh +cabal run simplex-directory-service -- +``` + +--- + +## Running the Bot + +### Getting your contact ID (bootstrap) + +`--super-users` is required and takes one or more `CONTACT_ID:DISPLAY_NAME` pairs. On a fresh database you don't yet know your contact ID, so start the bot with `--run-cli` and a placeholder, connect to it, then look up your real ID: + +```sh +simplex-directory-service --run-cli --super-users 999:nobody --database /path/to/db +# on startup the bot prints (and, unless --no-address, creates) its contact address: +# "Bot's contact address is: simplex:/contact#..." +# connect to it from your SimpleX Chat app, then in the bot terminal: +/contacts # lists connected contacts by name +/i alice # shows full info for contact "alice", including their contact ID +# note the ID, then restart without --run-cli: +simplex-directory-service --super-users 2:alice --database /path/to/db +``` + +In each `--super-users` / `--admin-users` pair both `contactId` and `localDisplayName` must match the contact exactly. (A mismatched name doesn't stop the bot from sending admin notifications to that contact ID, but commands from that contact will be rejected.) + +### Minimal run + +```sh +simplex-directory-service \ + --super-users 2:alice \ + --database /path/to/db +``` + +### Flags + +`simplex-directory-service` also accepts the standard SimpleX Chat core options (custom SMP/XFTP servers, `--socks-proxy`, network settings, etc.) — run `simplex-directory-service --help` for the complete list. The directory-specific options are: + +| Flag | Default | Description | +|---|---|---| +| `--super-users ID:NAME[,...]` | *(required)* | Super-user contacts (comma-separated) | +| `--admin-users ID:NAME[,...]` | none | Admin-only contacts (comma-separated) | +| `--owners-group ID:NAME` | none | Group (by group ID) that owners of listed groups are invited into — automatically on listing, or via `/invite` | +| `-d / --database PATH` | `~/.simplex/simplex_directory_service` | Database file path prefix | +| `--directory-file PATH` | none | Append-only log of directory state (see [Directory state log](#directory-state-log)) | +| `--migrate-directory-file check\|import\|export\|listing` | — | Check, import (log → DB), export (DB → log), or regenerate listing files, then exit | +| `--web-folder PATH` | none | Write static listing JSON + group images here (see [Hosting the directory page](#hosting-the-directory-page)) | +| `--no-address` | off | Skip checking/creating the bot's contact address | +| `--service-name NAME` | `SimpleX Directory` | Bot display name (without `*` characters) | +| `--profile-name-limit N` | unlimited | Max display-name length allowed to connect / join groups (used by the `name` join filter) | +| `--blocked-words-file PATH` | none | Words not allowed in profiles (used by the `name` join filter and profile review) | +| `--blocked-fragments-file PATH` | none | Word fragments not allowed in profiles | +| `--blocked-extenstion-rules PATH` | none | Substitution rules that expand the blocked-words list (the flag is spelled this way in the binary) | +| `--name-spelling-file PATH` | none | Character-substitution rules for matching disguised names | +| `--captcha-generator PATH` | none | Executable that renders a captcha image; without it captchas are sent as plain text | +| `--voice-captcha-generator PATH` | none | Executable that renders a voice captcha | +| `--run-cli` | off | Run an interactive CLI alongside the bot (useful for bootstrap) | +| `-v / --version` | — | Print version and exit | + +--- + +## Directory State Log + +`--directory-file` keeps an append-only log of every change to directory state (registrations, status changes, promotions) alongside the SQLite database. It is optional but recommended for operability: it is human-inspectable and can be checked, exported, or re-imported with `--migrate-directory-file`. Without it, directory state lives only in the database. + +--- + +## Hosting the Directory Page + +The `web/` folder contains a ready-to-use page (`directory.html`) that renders a bot's directory as a searchable, paginated list — no build step. Dark mode follows the system preference. + +A few things to know before deploying it: + +- **Copy the files out of the repo.** `web/directory.js` is a symlink to `website/src/js/directory.js` (kept in sync with the main website), so don't edit it in place — copy `directory.html` and the *contents* of `directory.js` to your web root and edit the copy. The data URL can't be overridden from `directory.html` because `directory.js` declares it as a top-level `const`. +- **Serve it under a `/directory` path.** `directory.js` only initialises when the page path starts with `/directory` (e.g. `https://example.com/directory.html` or `https://example.com/directory/`). +- **Provide the fallback image.** Groups without a profile image (and any image that fails to load) fall back to `/img/group.svg`, resolved from the site root — copy `website/src/img/group.svg` to `/img/group.svg`. +- **Point it at your bot's data.** Near the bottom of `directory.js`, change: + ```js + const simplexDirectoryDataURL = 'https://your-domain.example/data/'; + ``` + (the default is the official directory, `https://directory.simplex.chat/data/`). The page fetches `listing.json` from that URL and loads group images relative to it. +- **Optional:** the "Also available as a SimpleX chat bot" link in `directory.html` points at the official directory bot — update it to your bot's address. + +Then run the bot with `--web-folder` pointing at the folder served at that URL. The bot writes `listing.json` (used by the page), `promoted.json` (the promoted subset — written but not rendered by the bundled page), and group images, refreshing every 5 minutes and immediately when a group is approved or its listing/promotion status changes: +```sh +simplex-directory-service \ + --super-users 2:alice \ + --database /path/to/db \ + --web-folder /var/www/your-domain.example/data +``` + +--- + +## Command Reference + +This is a reference for all commands accepted by the SimpleX Directory bot. For a guided walkthrough of group submission see [DIRECTORY.md](../../docs/DIRECTORY.md). + +--- + +### 1. Searching + +The bot sends a welcome message automatically when you connect. + +| Action | Syntax | Effect | +|---|---|---| +| Search | `` | Returns up to 10 groups whose name or welcome message matches; sorted by member count | +| Next page | `/next` or `.` | Next page of the most recent search (after ~5 min of inactivity, or with no recent search, falls back to listing all groups) | +| Recent groups | `/new` | Groups added most recently | +| All groups | `/all` | All listed groups, sorted by member count | +| Help | `/help [registration\|r\|commands\|c]`, or `/h` | Show registration or commands help (default: registration) | + +--- + +### 2. Registering a Group + +Registration is a three-step process — see [DIRECTORY.md](../../docs/DIRECTORY.md) for full details: + +1. Invite the directory bot to your group as `admin`. +2. Add the link the bot sends you to the group's welcome message. +3. Wait for admin approval (usually within a day, except holidays). + +If a group with the same display name is already registered (but not yet listed or suspended), the bot asks you to confirm with `/confirm`. If the name is already listed or suspended in the directory, registration is blocked. + +--- + +### 3. Managing Your Groups (user commands) + +> **Note on `` vs `:`:** Commands shown with `[:]` (`/role`, `/filter`, `/link`) accept just the ID — the name is optional. `/confirm`, `/delete`, and all admin/super-user commands require both, written as `:`. For user commands the ID is the registration ID shown by `/list`; for admin and super-user commands it's the group ID included in the bot's admin notifications. When the bot expects an `:` argument it normally quotes the whole command for you (e.g. for `/confirm` and `/approve`), so you can copy it directly; for `/delete` you build it from the ID and name shown by `/list`. + +#### List groups + +``` +/list +/ls +``` + +Shows all groups you have registered, with their current status, member count, and the `/role` and `/filter` commands for each. + +#### Confirm duplicate name + +``` +/confirm : +``` + +When you invite the bot to a group whose display name matches an already-registered group, the bot pauses registration and asks for explicit confirmation. This prevents accidental registration of another group that shares a name. `/confirm` acknowledges the duplicate and proceeds with registration. `ID` and `name` are provided in the bot's prompt. + +If a group with the same name is already *listed* or *suspended* in the directory, registration is blocked entirely and `/confirm` is not offered. + +#### View or set default join role + +``` +/role [:] [member|observer] +``` + +Omit the role argument to view the current setting. `member` (default) lets new joiners post immediately; `observer` makes them read-only until promoted. + +#### View or configure anti-spam filter + +``` +/filter [:] [preset | flags] +``` + +Omit the argument to view the current filter. Filters apply to people joining via the directory-managed link. + +**Presets** (mutually exclusive): + +| Preset | Effect | +|---|---| +| `off` | No filter | +| `basic` | For profiles without an image: reject long or inappropriate names | +| `moderate` (or `mod`) | Reject long/inappropriate names from all profiles; require captcha from profiles without an image | +| `strong` | Reject long/inappropriate names from all profiles; require captcha from all profiles | + +**Flags** (combine freely): + +``` +/filter [:] [name[=all|=noimage]] [captcha[=all|=noimage]] [observer[=all|=noimage]] +``` + +| Flag | Condition | Effect | +|---|---|---| +| `name` | `=all` (default) or `=noimage` | Reject joins from profiles whose name is too long (`--profile-name-limit`) or contains blocked words/fragments (`--blocked-words-file` / `--blocked-fragments-file`) | +| `captcha` | `=all` (default) or `=noimage` | Require the joiner to solve a captcha | +| `observer` | `=all` (default) or `=noimage` | Make new members observers instead of members | + +`=noimage` means the condition applies only to profiles that have no profile image (`=no_image` and `=no-image` are also accepted). + +> The `name` filter only has an effect when the bot was started with `--profile-name-limit` and/or `--blocked-words-file` / `--blocked-fragments-file`; otherwise it does nothing. The `captcha` filter sends a much stronger challenge when `--captcha-generator` (or `--voice-captcha-generator`) is configured — without it the captcha text is sent as a plain message. + +#### View or upgrade group link + +``` +/link [:] +``` + +Shows the current directory-managed join link. If the link is outdated the bot upgrades it. + +#### Remove group from directory + +``` +/delete : +``` + +Permanently removes the group from the directory. The group can be re-registered later. + +--- + +### 4. Admin Commands + +Admins receive a notification whenever a group enters the PendingApproval state. The `` in `/approve` is the integer from that notification (or from `/pending`); it increments each time a group re-enters the PendingApproval state so that stale approvals are rejected. + +| Command | Syntax | Effect | +|---|---|---| +| Approve | `/approve : [promote=on\|off]` | List group in directory; notifies owner | +| Suspend | `/suspend :` | Hide group from directory; notifies owner | +| Resume | `/resume :` | Re-list a suspended group; notifies owner | +| List recent | `/last [N]` | Show last N registered groups (default: 10) | +| List pending | `/pending [N]` | Show N groups awaiting approval (default: 10) | +| Message owner | `/owner : ` | Forward a message to the group owner | +| Reject | `/reject :` | *(Reserved, currently a no-op)* | +| Invite owner | `/invite :` | Invite the group owner to the owners' group (requires `--owners-group`) | + +--- + +### 5. Super-User Commands + +| Command | Syntax | Effect | +|---|---|---| +| Feature group | `/promote : on\|off` | Add or remove group from the promoted listing (`promoted.json`) | +| Execute API command | `/exec ` or `/x ` | Run a raw SimpleX Chat API command | + +--- + +### 6. Group Lifecycle + +Forward path, from invitation to being listed: + +``` + Invited by owner + │ + ┌────────────┴────────────┐ + unique name duplicate name* + │ │ + │ ▼ + │ PendingConfirmation + │ │ owner runs /confirm + └────────────┬─────────────┘ + ▼ + Proposed + │ bot joins the group and creates the link + ▼ + PendingUpdate + │ owner adds the link to the group welcome + ▼ + PendingApproval + │ admin runs /approve + ▼ + Active (listed; visible in search) +``` + +**Transitions out of Active:** + +- → **PendingUpdate** — the directory bot link is removed from the welcome message. +- → **PendingApproval** — most other profile changes (see ** below); the `approval-id` shown to admins is bumped each time, so stale `/approve` commands are rejected. +- → **Suspended** — an admin runs `/suspend`; `/resume` re-lists the group. +- → **SuspendedBadRoles** — the directory bot loses its `admin` role, or the registering owner loses their `owner` role, in the group; automatically restored to **Active** once the roles are corrected. +- → **Removed** — the owner runs `/delete`, the owner is removed from or leaves the group, the bot is removed from the group, or the group is deleted. The group can be re-registered afterwards. + +\* Only when the duplicate is registered but not yet listed or suspended. If the name is already listed or suspended, registration is blocked entirely. + +\*\* Profile changes only trigger re-approval when fields other than the directory bot link are modified. If the only change is swapping the old bot link for the new one, or changing only whitespace in the description, the group stays Active. + +**State notes:** + +- **PendingConfirmation** — the bot was invited but a group with the same display name is already registered (in a pending state); the owner must run `/confirm` to proceed. +- **Proposed** — the name is unique (or the duplicate was confirmed via `/confirm`); the bot is joining the group. +- **PendingUpdate** — the bot has joined the group and created the join link; the owner must add it to the group's welcome message. +- **PendingApproval** — submitted for admin review. The join link works even before approval. +- **Active** — listed in the directory and visible in search results. diff --git a/apps/simplex-directory-service/web/directory.html b/apps/simplex-directory-service/web/directory.html new file mode 100644 index 0000000000..d68d624ab6 --- /dev/null +++ b/apps/simplex-directory-service/web/directory.html @@ -0,0 +1,224 @@ + + + + + + SimpleX Directory + + + + + +
+

SimpleX Directory

+

Communities you can join via SimpleX Chat.

+

Also available as a SimpleX chat bot.

+
+ + +
+
+ +
+ + + + + diff --git a/apps/simplex-directory-service/web/directory.js b/apps/simplex-directory-service/web/directory.js new file mode 120000 index 0000000000..197f1d6e66 --- /dev/null +++ b/apps/simplex-directory-service/web/directory.js @@ -0,0 +1 @@ +../../../website/src/js/directory.js \ No newline at end of file diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index a1afd7a660..37521e49dc 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -1068,24 +1068,26 @@ describe("Team Member Lifecycle", () => { expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner)).toBe(true) }) - test("/team invites team member → apiSetMembersRole(Owner) called at invite time", async () => { + test("/team invites team member → added directly as Owner, single invitation", async () => { await bot.onNewChatItems(customerMessage("/team")) - expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) - expect(chat.roleChanges.some(r => - r.groupId === CUSTOMER_GROUP_ID - && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) - && r.role === GroupMemberRole.Owner + expect(chat.added.some(a => + a.groupId === CUSTOMER_GROUP_ID + && a.contactId === TEAM_MEMBER_1_ID + && a.role === GroupMemberRole.Owner )).toBe(true) + // No role change — that would send a second invitation. + expect(chat.roleChanges.length).toBe(0) }) - test("/join invites team member → apiSetMembersRole(Owner) called at invite time", async () => { + test("/join invites team member → added directly as Owner, single invitation", async () => { await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) - expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) - expect(chat.roleChanges.some(r => - r.groupId === CUSTOMER_GROUP_ID - && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) - && r.role === GroupMemberRole.Owner + expect(chat.added.some(a => + a.groupId === CUSTOMER_GROUP_ID + && a.contactId === TEAM_MEMBER_1_ID + && a.role === GroupMemberRole.Owner )).toBe(true) + // No role change — that would send a second invitation. + expect(chat.roleChanges.length).toBe(0) }) test("/team when team member already in group (any non-terminal status) → apiSetMembersRole NOT re-called", async () => { diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 9b534381de..28b2080793 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -853,17 +853,12 @@ export class SupportBot { const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) const existing = members.find(m => m.memberContactId === teamContactId && isInGroup(m)) if (existing) return existing - const member = await this.withMainProfile(() => - this.chat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + // Invite directly as Owner in one call. Adding as Member then promoting + // sent a second invitation, since the API re-sends it when a pending + // (GSMemInvited) member's role changes. onMemberConnected re-asserts Owner. + return this.withMainProfile(() => + this.chat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Owner) ) - try { - await this.withMainProfile(() => - this.chat.apiSetMembersRole(groupId, [member.groupMemberId], T.GroupMemberRole.Owner) - ) - } catch { - // Not yet connected — will be promoted in onMemberConnected - } - return member } async sendToGroup(groupId: number, text: string): Promise { 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/cabal.project b/cabal.project index 8232f49998..3c0526fa6e 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f7e8ed52bf4005f914916bd209f3d10f37d8ef70 + tag: efaad8e73436d60f5052f07dda6b71151ad5039b source-repository-package type: git 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/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-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-23-fix-updater-download-deleted-by-chat-switch.md b/plans/2026-07-23-fix-updater-download-deleted-by-chat-switch.md new file mode 100644 index 0000000000..29690edd17 --- /dev/null +++ b/plans/2026-07-23-fix-updater-download-deleted-by-chat-switch.md @@ -0,0 +1,144 @@ +# Fix: chat switch deletes the in-app updater's download mid-transfer + +## Symptom + +Desktop in-app updater (Linux AppImage, v7.0.0-beta.5): the download progress reaches 100%, and then +nothing happens — no "App update is downloaded" dialog, no error, and `/tmp/simplex` is empty. +The update cannot proceed and there is no indication of why. + +Captured by starting the app from a terminal (the log goes nowhere otherwise — see *Why this was invisible*): + +``` +E: Failed to download the asset from release: java.nio.file.NoSuchFileException: /tmp/simplex/e859e2cb-b4c6-4ebf-812d-c327812db212 + at java.base/sun.nio.fs.UnixCopyFile.move(Unknown Source) + at java.base/java.nio.file.Files.move(Unknown Source) + at chat.simplex.common.views.helpers.AppUpdaterKt.downloadAsset$lambda$...(AppUpdater.kt:333) + at chat.simplex.common.views.helpers.UtilsKt.createTmpFileAndDelete(Utils.kt:401) + at chat.simplex.common.views.helpers.AppUpdaterKt.downloadAsset(AppUpdater.kt:323) +``` + +The exception names the **source** path, not the destination: the file being moved no longer exists. + +## Root cause + +The updater downloads into `createTmpFileAndDelete`, which registers every file it creates in a +chat-scoped cleanup set: + +```kotlin +fun createTmpFileAndDelete(dir: File = tmpDir, onCreated: (File) -> T): T { + val tmpFile = File(dir, UUID.randomUUID().toString()) + tmpFile.parentFile.mkdirs() + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) // <-- kills the download + try { return onCreated(tmpFile) } finally { tmpFile.delete() } +} +``` + +`ComposeView.deleteUnusedFiles()` empties that set: + +```kotlin +chatModel.filesToDelete.forEach { it.delete() } +chatModel.filesToDelete.clear() +``` + +and it is called from `KeyChangeEffect(chatModel.chatId.value)` — on chat open, switch and close, +whenever the composer is empty and there is no draft (`ComposeView.kt:1320`), plus after sending a +live message (`:1301`) and a voice message (`:860`). + +`downloadAsset` writes the ~350 MB update into that temp file, which takes minutes, and the toast shown +during it ("Downloading app update, don't close the app") invites the user to keep using the app +meanwhile. Any chat switch in that window deletes the file out from under the running download. +`copyTo` keeps writing through the still-open file descriptor, so the transfer completes normally and +the progress indicator reaches 100%; only the subsequent `Files.move` fails. + +The failure is then swallowed by `downloadAsset`'s outer `catch`, which only calls `Log.e` — on desktop +that is a bare `println` (`Log.desktop.kt`), invisible unless the app was launched from a terminal. +Net effect: silence. + +This is not specific to the updater. The same window exists for the settings and themes writers +(`Resources.desktop.kt`, `Files.kt`), which use the same helper and then `Files.move` the temp file into +place; a chat switch landing between the write and the move surfaces there as an +"Error saving settings" alert and a rethrow. + +### Why this was invisible until v7.0-beta.1 + +Before #7104 the same line was `file.renameTo(newFile)`, whose `false` return was ignored, so the +dialog appeared regardless — over an empty folder (that was the bug #7104 fixed). Replacing it with +`Files.move` was correct, but it converted a silently-ignored failure into a thrown exception routed +into a stdout-only log, which turned the visible-but-wrong symptom into no symptom at all. The +underlying deletion predates both. + +## Fix + +`downloadAsset` stops using `createTmpFileAndDelete` and owns the file itself: + +```kotlin +val newFile = File(tmpDir, asset.name) +if (desktopPlatform.isWindows()) newFile.delete() +val partFile = File(tmpDir, "${asset.name}.part") +partFile.parentFile.mkdirs() +partFile.deleteOnExit() +try { + partFile.outputStream().use { output -> stream.copyTo(output) } + Files.move(partFile.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + AlertManager.shared.showAlertDialogButtonsColumn(...) // unchanged +} finally { + partFile.delete() +} +``` + +The download is never registered in `ChatModel.filesToDelete`, so `deleteUnusedFiles()` cannot reach +it and a chat switch during the transfer is harmless. Cleanup is unchanged in substance: the `finally` +removes a partial file when the download fails or is cancelled (and is a no-op after a successful +move, exactly as the helper's own `finally` was), `deleteOnExit()` covers a clean exit, and `Main.kt` +wipes `tmpDir` at startup after a crash — a leftover `.part` is also truncated by the next download. + +This is the fix requested in review (#7295): the helper's contract is a scratch file that dies with +the lambda, and the updater's file is neither — it is moved to `asset.name` and kept for the user to +install, so the helper's `finally` never applied to it on the success path. `createTmpFileAndDelete` +is left as it is on master, with a comment warning that its file does not survive a chat switch. + +## Other callers + +Unchanged. The same trap still exists for anyone whose lambda outlives a chat switch, but no current +caller does: the settings/themes writers (`Resources.desktop.kt` ×2, `Files.kt`) write and `Files.move` +within milliseconds, and `CIFileView.kt`, `Utils.kt` and `Share.android.kt` finish with the file inside +the lambda. See "Follow-up" below. + +## Alternatives considered + +- **Removing `ChatModel.filesToDelete.add(tmpFile)` from the helper** (the first version of this PR) — + fixes the whole class rather than one caller, and the registration is redundant with the helper's + own `finally` at every call site. Rejected in review as too broad for this bug: it changes behaviour + at six unrelated call sites to fix the updater. Kept as a follow-up, see below. +- **`filesToDelete.remove(file)` at the updater's call site** — leaves a window between the `add` + inside the helper and the `remove` in the lambda, and adds a third concurrent mutation of + `ChatModel.filesToDelete` (a plain `mutableSetOf`, already mutated from both `Dispatchers.Default` + and the main thread). + +## Follow-up (not in this PR) + +`createTmpFileAndDelete` still registers its file in `ChatModel.filesToDelete` from whatever thread +calls it, including `Dispatchers.IO` (settings save, `saveFileFromUri`), while +`ComposeView.deleteUnusedFiles()` iterates that plain `mutableSetOf` on the UI thread — a concurrent +`add` during that `forEach` throws `ConcurrentModificationException`. The registration is also +redundant with the helper's `finally` at all six call sites. Removing it, or making the set +thread-safe, is a separate change. + +Note also that `preferencesTmpDir` on desktop is `File(configPath, "tmp")` — outside `tmpDir`, so it is +not covered by the startup wipe in `Main.kt`; its temp files rely on the `finally` and `deleteOnExit`. + +## Testing + +Repro and verification were done against the first version of this fix: with `desktop.version_name` +temporarily set to `7.0-beta.4` the updater offers v7.0.0-beta.5, and switching chats repeatedly during +the download reproduces the disappearing file and the missing dialog. This version compiles +(`:common:compileKotlinDesktop`); the manual download-and-switch run should be repeated before merge, +including the install path on Windows (where the app exits before deleting the file). + +## Out of scope + +`downloadAsset` and `checkForUpdate` report failures only via `Log.e`, which on desktop reaches nothing +but stdout — that is why this bug went unnoticed. Surfacing updater failures to the user is a separate +concern: it would not have prevented the deletion, and this change does not make future failures +visible. Left for a follow-up. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index cde8e34335..5d70ed7b94 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f7e8ed52bf4005f914916bd209f3d10f37d8ef70" = "0gd476792lfpx2andj0l6hhyvpcngb7v2pc8v0h718i7r2fwvd51"; + "https://github.com/simplex-chat/simplexmq.git"."efaad8e73436d60f5052f07dda6b71151ad5039b" = "1jczm6baqz34sn13jgp5srqjk3gnx90brsfrsqw29al769ssrvkv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6e82981f48..bf4dc7e10b 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.10 +version: 7.0.0.11 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4d1de3bc72..2226f80fab 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 @@ -2880,14 +2879,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) $ @@ -2908,22 +2907,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 7bddfcaba5..6fb002eaee 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 @@ -1288,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. @@ -2813,7 +2811,11 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem where groupMentions db g membership = do mentions' <- getRcvCIMentions db user g ft_ mentions - let userReply = case cmToQuotedMsg chatMsgEvent of + -- messages of blocked members are hidden, they should not mention user + let senderBlocked = case cd of + CDGroupRcv _g _scope m -> memberBlocked m + _ -> False + userReply = not senderBlocked && case cmToQuotedMsg chatMsgEvent of Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership _ -> False userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' @@ -3245,21 +3247,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 28515aa068..a65bed75a0 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2221,7 +2221,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = toView $ CEvtChatItemsDeleted user deletions False False -- m' is Maybe GroupMember createNonLive gInfo' m' scopeInfo file_ = do - saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') False mentions + let mentions' = if maybe False memberBlocked m' then M.empty else mentions + saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') False mentions' createContentItem gInfo' m' scopeInfo = do file_ <- processFileInv gInfo' m' newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') live' @@ -3337,6 +3338,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 +3365,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 +3877,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 +3886,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/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/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/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 767728e400..37827ab2ad 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -213,6 +213,8 @@ chatGroupTests = do it "repeat block, unblock" testBlockForAllRepeat it "block multiple members" testBlockForAllMultipleMembers it "block left/removed members" testBlockForAllLeftRemoved + it "mentions of blocked member are ignored" testBlockForAllMentionsIgnored + it "replies of blocked member are not mentions" testBlockedMemberReplyNotMention describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do @@ -240,6 +242,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 @@ -6920,6 +6923,73 @@ testBlockForAllMarkedBlocked = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) +testBlockForAllMentionsIgnored :: HasCallStack => TestParams -> IO () +testBlockForAllMentionsIgnored = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + threadDelay 1000000 + + -- mention of user is shown as mention ("!" after member name) + bob #> "#team hello @alice" + alice <# "#team bob!> hello @alice" + cath <# "#team bob> hello @alice" + + threadDelay 1000000 + + alice ##> "/block for all #team bob" + alice <## "#team: you blocked bob" + cath <## "#team: alice blocked bob" + bob "#team hello again @alice" + alice <# "#team bob> hello again @alice [blocked by admin] " + cath <# "#team bob> hello again @alice [blocked by admin] " + +testBlockedMemberReplyNotMention :: HasCallStack => TestParams -> IO () +testBlockedMemberReplyNotMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + threadDelay 1000000 + + bob #> "#team hi" + alice <# "#team bob> hi" + cath <# "#team bob> hi" + + threadDelay 1000000 + + -- reply to user message is shown as mention ("!" after member name) + alice `send` "> #team @bob (hi) hey bob!" + alice <# "#team > bob hi" + alice <## " hey bob!" + bob <# "#team alice!> > bob hi" + bob <## " hey bob!" + cath <# "#team alice> > bob hi" + cath <## " hey bob!" + + threadDelay 1000000 + + -- admins can only block for all, blocking for self via api + bob ##> "/_member settings #1 1 {\"showMessages\": false}" + bob <## "ok" + + threadDelay 1000000 + + -- reply of blocked member is ignored (no "!" after member name) + alice `send` "> #team @bob (hi) hey again!" + alice <# "#team > bob hi" + alice <## " hey again!" + bob <#. "#team alice> > bob hi" + bob <##. " hey again!" + cath <# "#team alice> > bob hi" + cath <## " hey again!" + testBlockForAllFullDelete :: HasCallStack => TestParams -> IO () testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ @@ -8462,6 +8532,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 4468756498..4b82779b0e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -3934,14 +3934,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" @@ -3955,8 +3955,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 @@@ [] @@ -3984,16 +3984,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" @@ -4012,8 +4012,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 @@@ [] @@ -4106,8 +4106,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" @@ -4220,7 +4220,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"