Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin committed 2026-07-25 12:31:05 +01:00
commit 0bd88db2d4
54 files changed
+1346 -183

No files matched your search

+5 -7
View File
@@ -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
@@ -638,6 +638,7 @@ struct ChatListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: Set<String>
@Binding var parentSheet: SomeSheet<AnyView>?
@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 }
@@ -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]? {
@@ -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))
}
@@ -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 {
@@ -6978,8 +6978,8 @@ Enable in *Network &amp; servers* settings.</source>
<source>Register</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -6767,8 +6767,8 @@ Enable in *Network &amp; servers* settings.</source>
<source>Register</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7466,8 +7466,8 @@ Aktivieren Sie es in den *Netzwerk und Server* Einstellungen.</target>
<target>Registrieren</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7482,9 +7482,9 @@ Enable in *Network &amp; servers* settings.</target>
<target>Register</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<target>Register a test name</target>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<target>How to register a test name</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7466,8 +7466,8 @@ Actívalo en ajustes de *Servidores y Redes*.</target>
<target>Registrar</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -6647,8 +6647,8 @@ Enable in *Network &amp; servers* settings.</source>
<source>Register</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7434,8 +7434,8 @@ Activez-le dans les paramètres *Réseau et serveurs*.</target>
<target>Inscrire</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7466,8 +7466,8 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben.</target>
<target>Regisztrálás</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7466,8 +7466,8 @@ Attivalo nelle impostazioni *Rete e server*.</target>
<target>Registra</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -6767,8 +6767,8 @@ Enable in *Network &amp; servers* settings.</source>
<source>Register</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7221,8 +7221,8 @@ Schakel dit in in *Netwerk en servers*-instellingen.</target>
<target>Register</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7286,8 +7286,8 @@ Włącz w ustawianiach *Sieć i serwery* .</target>
<target>Zarejestruj</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7423,8 +7423,8 @@ Enable in *Network &amp; servers* settings.</source>
<target>Зарегистрировать</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -6625,8 +6625,8 @@ Enable in *Network &amp; servers* settings.</source>
<source>Register</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7282,8 +7282,8 @@ Enable in *Network &amp; servers* settings.</source>
<target>Kaydol</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7293,8 +7293,8 @@ Enable in *Network &amp; servers* settings.</source>
<target>Зареєструватися</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -7465,8 +7465,8 @@ Enable in *Network &amp; servers* settings.</source>
<target>注册</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register a test name" xml:space="preserve">
<source>Register a test name</source>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Register notification token?" xml:space="preserve">
@@ -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),
@@ -396,7 +396,7 @@
<string name="save_simplex_name_question">Save SimpleX name?</string>
<string name="get_simplex_name_beta">Get SimpleX name (BETA)</string>
<string name="channel_simplex_name">Channel SimpleX name</string>
<string name="register_test_name">Register a test name</string>
<string name="register_test_name">How to register a test name</string>
<string name="remove_name">Remove name</string>
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
@@ -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()
}
}
}
@@ -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
* */
+306 -2
View File
@@ -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 -- <flags>
```
---
## 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 `<web-root>/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 | `<text>` | 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 `<ID>` vs `<ID>:<name>`:** Commands shown with `<ID>[:<name>]` (`/role`, `/filter`, `/link`) accept just the ID — the name is optional. `/confirm`, `/delete`, and all admin/super-user commands require both, written as `<ID>:<name>`. 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 `<ID>:<name>` 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 <ID>:<name>
```
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 <ID>[:<name>] [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 <ID>[:<name>] [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 <ID>[:<name>] [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 <ID>[:<name>]
```
Shows the current directory-managed join link. If the link is outdated the bot upgrades it.
#### Remove group from directory
```
/delete <ID>:<name>
```
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 `<approval-id>` 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 <ID>:<name> <approval-id> [promote=on\|off]` | List group in directory; notifies owner |
| Suspend | `/suspend <ID>:<name>` | Hide group from directory; notifies owner |
| Resume | `/resume <ID>:<name>` | 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 <ID>:<name> <message>` | Forward a message to the group owner |
| Reject | `/reject <ID>:<name>` | *(Reserved, currently a no-op)* |
| Invite owner | `/invite <ID>:<name>` | Invite the group owner to the owners' group (requires `--owners-group`) |
---
### 5. Super-User Commands
| Command | Syntax | Effect |
|---|---|---|
| Feature group | `/promote <ID>:<name> on\|off` | Add or remove group from the promoted listing (`promoted.json`) |
| Execute API command | `/exec <command>` or `/x <command>` | 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.
@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleX Directory</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
background: #fff;
color: #111;
}
@media (prefers-color-scheme: dark) {
body { background: #061f38; color: #f0f0f0; }
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: 700;
text-align: center;
color: #0053d0;
margin: 0 0 12px;
}
@media (prefers-color-scheme: dark) {
h1 { color: #70f0f9; }
}
p { margin: 0 0 8px; }
a { color: #0053d0; }
@media (prefers-color-scheme: dark) { a { color: #70f0f9; } }
/* ── Directory entries ── */
#directory .entry {
display: flex;
align-items: flex-start;
margin-bottom: 20px;
padding: 16px;
word-break: break-word;
}
#directory .entry a {
order: -1;
object-fit: cover;
margin-right: 16px;
margin-bottom: 16px;
}
#directory .entry a img {
min-width: 104px;
min-height: 104px;
width: 104px;
height: 104px;
border-radius: 24px;
}
#directory .entry h2 { margin: 0 0 5px; }
#directory .entry p { margin: 0 0 5px; }
#directory .entry .secret {
filter: blur(5px);
cursor: pointer;
transition: filter 0.1s ease;
user-select: none;
}
#directory .entry .secret.visible { filter: none; user-select: auto; }
#directory .entry .read-more {
color: #0053d0;
text-decoration: underline;
cursor: pointer;
}
#directory .entry .read-less { color: darkgray; cursor: pointer; }
.dark #directory .entry .read-more { color: #70f0f9; }
#directory .entry .red { color: #dd0000; }
#directory .entry .green { color: #20bd3d; }
#directory .entry .blue { color: #0053d0; }
#directory .entry .cyan { color: #0ac4d1; }
#directory .entry .yellow { color: #debd00; }
#directory .entry .magenta { color: magenta; }
#directory .entry .small-text { font-size: 0.8em; color: #888; }
.dark #directory .entry .small-text { color: #999; }
.dark #directory .entry .green { color: #4dda67; }
.dark #directory .entry .blue { color: #00a2ff; }
.dark #directory .entry .cyan { color: #70f0f9; }
.dark #directory .entry .yellow { color: #ffd700; }
/* ── Pagination ── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
margin-top: 20px;
padding: 10px 0;
}
.pagination button {
padding: 8px 12px;
border: none;
background: transparent;
color: #374151;
cursor: pointer;
border-radius: 50%;
font-size: 14px;
min-width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.dark .pagination button { color: #70f0f9; }
.pagination button:hover { background: #f3f4f6; }
.dark .pagination button:hover { background: #0b2a50; }
.pagination button.active { font-weight: bold; color: #0b2a59; }
.dark .pagination button.active { color: #70f0f9; }
.pagination button.text-btn { border-radius: 20px; min-width: auto; height: 40px; padding: 8px 16px; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 768px) {
.pagination { gap: 2px; padding: 8px 0; }
.pagination button { font-size: 12px; min-width: 32px; height: 32px; padding: 4px 8px; }
.pagination button.text-btn { padding: 4px 8px; height: 32px; border-radius: 16px; font-size: 12px; }
}
@media (max-width: 480px) {
.pagination button:not(.text-btn):not(.active):not(.neighbor) { display: none; }
.pagination button.active, .pagination button.neighbor { display: flex; }
}
/* ── Search ── */
.search-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
#search {
flex-grow: 1;
width: 100%;
max-width: 540px;
padding: 8px 12px 8px 32px;
font-size: 15px;
font-family: inherit;
border: none;
border-radius: 10px;
background-color: #fff;
color: #000;
outline: solid 1px #f2f2ff;
transition: background-color 0.2s, box-shadow 0.2s;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0iIzg4ODg4OCI+CjxjaXJjbGUgY3g9IjEwLjUiIGN5PSIxMC41IiByPSI3LjUiIC8+CjxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjEiIHkyPSIyMSIgLz4KPC9nPgo8L3N2Zz4=');
background-position: 8px center;
background-repeat: no-repeat;
background-size: 18px;
}
#search::placeholder { color: #8e8e93; }
.dark #search {
background-color: #0b2a50;
color: #fff;
background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0iI2JiYmJiYiI+CjxjaXJjbGUgY3g9IjEwLjUiIGN5PSIxMC41IiByPSI3LjUiIC8+CjxsaW5lIHgxPSIxNiIgeTE9IjE2IiB4Mj0iMjEiIHkyPSIyMSIgLz4KPC9nPgo8L3N2Zz4=');
outline: none;
}
.dark #search::placeholder { color: #8e8e93; }
#top-pagination { margin-bottom: 0; }
</style>
</head>
<body>
<script>
// Mirror system dark-mode preference into the .dark class used by directory.js
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
document.body.classList.toggle('dark', e.matches);
});
</script>
<div class="container">
<h1>SimpleX Directory</h1>
<p>Communities you can join via <a href="https://simplex.chat/downloads" target="_blank">SimpleX Chat</a>.</p>
<p>Also available as a <a href="https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok" target="_blank">SimpleX chat bot</a>.</p>
<div class="search-container">
<input id="search" autocomplete="off">
<div id="top-pagination" class="pagination">
<button class="text-btn live">Active</button>
<button class="text-btn new">New</button>
<button class="text-btn top">All</button>
</div>
</div>
<div id="directory" style="min-height: 200px;"></div>
<div id="bottom-pagination" class="pagination"></div>
</div>
<script>
const isMobile = {
Android: () => navigator.userAgent.match(/Android/i),
iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i),
any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i)
};
</script>
<script src="directory.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
../../../website/src/js/directory.js
+14 -12
View File
@@ -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 () => {
+5 -10
View File
@@ -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<void> {
@@ -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 &mdash; 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) &mdash; you must install v6.5 to join it &mdash; 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 &mdash; see *What's new* in the app or full release notes.
+67
View File
@@ -0,0 +1,67 @@
---
layout: layouts/article.html
title: "SimpleX Public Names &mdash; a Name Nobody Can Take From You"
date: 2026-07-22
preview: "You can now give your channel or business a test SimpleX name &mdash; from v7-beta."
image: images/20260722-register-name.png
imageWide: true
permalink: "/blog/20260722-simplex-public-names.html"
---
# SimpleX Public Names &mdash; 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 &mdash; without user IDs
<img src="./images/20260722-phone-name.webp" width="19%" class="float-to-right dark:hidden">
<img src="./images/20260722-phone-name-light.webp" width="19%" class="float-to-right hidden dark:block">
Before names, the only way to bring people to your channel or business on SimpleX Network was a link &mdash; 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 &mdash; 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 &mdash; channels, businesses, communities &mdash; 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
<img src="./images/20260722-register-name.png" width="38%" class="float-to-right">
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 &mdash; currently 6 characters or more &mdash; 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 &mdash; 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
<img src="./images/20260722-connect-name.png" width="288" class="float-to-right">
Type the name into the search bar &mdash; `#example.testing` to join the channel, `@example.testing` to send direct messages. You can also send names in messages &mdash; 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) &mdash; 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 &mdash; 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 &mdash; 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).
+14
View File
@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
@@ -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) <id>`. 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.
@@ -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".
@@ -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 <T> 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.
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+12 -12
View File
@@ -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
+8 -21
View File
@@ -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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 truncated
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
+14 -1
View File
@@ -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
+7
View File
@@ -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
+2
View File
@@ -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)
@@ -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 = ?
+24 -24
View File
@@ -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 ()
+107
View File
@@ -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 <// 50000
threadDelay 1000000
-- mention of blocked member is ignored (no "!" after member name)
bob #> "#team hello again @alice"
alice <# "#team bob> hello again @alice [blocked by admin] <muted>"
cath <# "#team bob> hello again @alice [blocked by admin] <muted>"
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
+14 -14
View File
@@ -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"