diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 9987fb4697..5213b5509b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -31,11 +31,11 @@ func apiLoadMessages( let chatModel = ChatModel.shared - // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - // When openAroundItemId is provided, chatId can be different too - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled { + // For .initial allow the chatItems to be empty, as well as for .last that is used for searching + let allowEmptyItems = paginationIsInitial || paginationIsLast || openAroundItemId != nil + if chatWasSwitched(chat, pagination, openAroundItemId) || (!allowEmptyItems && chat.chatItems.isEmpty) || Task.isCancelled { return } @@ -79,6 +79,7 @@ func apiLoadMessages( newItems.insert(contentsOf: chat.chatItems, at: insertAt) let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = newReversed im.chatState.splits = modifiedSplits.newSplits im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) @@ -99,6 +100,7 @@ func apiLoadMessages( let new: [ChatItem] = newItems let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = newReversed im.chatState.splits = newSplits im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new) @@ -122,6 +124,7 @@ func apiLoadMessages( let newReversed: [ChatItem] = newItems.reversed() let orderedSplits = newSplits await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = newReversed im.chatState.splits = orderedSplits im.chatState.unreadAfterItemId = chat.chatItems.last!.id @@ -147,6 +150,7 @@ func apiLoadMessages( newItems.append(contentsOf: chat.chatItems) let items = newItems await MainActor.run { + if chatWasSwitched(chat, pagination, openAroundItemId) { return } im.reversedChatItems = items.reversed() im.chatState.splits = newSplits if im.secondaryIMFilter == nil { @@ -158,6 +162,14 @@ func apiLoadMessages( } +/// .initial pagination and opening around item set ChatModel.chatId themselves after the items are loaded. +/// In other cases the chat could be switched while the items were loading, and the items of the previously opened chat +/// must not be added to the items of the currently opened one +private func chatWasSwitched(_ chat: Chat, _ pagination: ChatPagination, _ openAroundItemId: ChatItem.ID?) -> Bool { + let paginationIsInitial = switch pagination { case .initial: true; default: false } + return !paginationIsInitial && openAroundItemId == nil && ChatModel.shared.chatId != chat.id +} + private class ModifiedSplits { let oldUnreadSplitIndex: Int let newUnreadSplitIndex: Int diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 61098f95cc..1a0fe5eede 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -191,8 +191,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -580,8 +580,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -752,8 +752,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -840,8 +840,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-LojM8yKwVXIArAtVCk2ZRs.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */, ); path = Libraries; sourceTree = ""; @@ -2123,7 +2123,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2173,7 +2173,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2215,7 +2215,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2235,7 +2235,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2260,7 +2260,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2297,7 +2297,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2334,7 +2334,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2385,7 +2385,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2439,7 +2439,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2473,7 +2473,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 352; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 6562d40cec..fd912a8c9e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -33,13 +33,21 @@ suspend fun apiLoadMessages( visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), contentTag ?: chatsCtx.contentTag, pagination, search) ?: return@coroutineScope - // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes - /** When [openAroundItemId] is provided, chatId can be different too */ - if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) + // For .initial allow the chatItems to be empty, as well as for .last that is used for searching + val allowEmptyItems = pagination is ChatPagination.Initial || pagination is ChatPagination.Last || openAroundItemId != null + if (chatWasSwitched(chat, pagination, openAroundItemId) + || (!allowEmptyItems && chat.chatItems.isEmpty()) || !isActive) return@coroutineScope processLoadedChat(chatsCtx, chat, navInfo, pagination, openAroundItemId, visibleItemIndexesNonReversed) } +/** .initial pagination and opening around item set [ChatModel.chatId] themselves after the items are loaded. + * In other cases the chat could be switched while the items were loading, and the items of the previously opened chat + * must not be added to the items of the currently opened one */ +private fun chatWasSwitched(chat: Chat, pagination: ChatPagination, openAroundItemId: Long?): Boolean = + pagination !is ChatPagination.Initial && openAroundItemId == null && + (chatModel.chatId.value != chat.id || chatModel.remoteHostId() != chat.remoteHostId) + suspend fun processLoadedChat( chatsCtx: ChatModel.ChatsContext, chat: Chat, @@ -94,6 +102,7 @@ suspend fun processLoadedChat( val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) @@ -113,6 +122,7 @@ suspend fun processLoadedChat( val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) @@ -135,6 +145,7 @@ suspend fun processLoadedChat( newSplits.add(splitIndex, chat.chatItems.last().id) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id @@ -158,6 +169,7 @@ suspend fun processLoadedChat( removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) withContext(Dispatchers.Main) { + if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext chatsCtx.chatItems.replaceAll(newItems) chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatItemsLoaderTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatItemsLoaderTest.kt new file mode 100644 index 0000000000..3337839ff0 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatItemsLoaderTest.kt @@ -0,0 +1,65 @@ +package chat.simplex.app + +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.processLoadedChat +import chat.simplex.common.model.replaceAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChatItemsLoaderTest { + + private fun groupChat(groupId: Long, itemIds: List): Chat = + Chat( + remoteHostId = null, + chatInfo = ChatInfo.Group(GroupInfo.sampleData.copy(groupId = groupId), groupChatScope = null), + chatItems = itemIds.map { ChatItem.getSampleData(it, CIDirection.GroupRcv(GroupMember.sampleData)) } + ) + + private suspend fun openChatWithItems(itemIds: List): Pair { + val chatsCtx = ChatModel.ChatsContext(null) + val opened = groupChat(groupId = 2, itemIds = itemIds) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(opened.chatItems) + chatModel.chatId.value = opened.chatInfo.id + } + return chatsCtx to opened + } + + private fun itemIds(chatsCtx: ChatModel.ChatsContext): List = chatsCtx.chatItems.value.map { it.id } + + @Test + fun lastPageLoadedForAnotherChatIsNotAddedToOpenedChat() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val anotherChat = groupChat(groupId = 1, itemIds = listOf(201, 202)) + processLoadedChat(chatsCtx, anotherChat, NavigationInfo(), ChatPagination.Last(2), openAroundItemId = null) + assertEquals(listOf(101L, 102L), itemIds(chatsCtx)) + } + + @Test + fun lastPageLoadedForOpenedChatIsAdded() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val sameChat = groupChat(groupId = 2, itemIds = listOf(103, 104)) + processLoadedChat(chatsCtx, sameChat, NavigationInfo(), ChatPagination.Last(2), openAroundItemId = null) + assertEquals(listOf(101L, 102L, 103L, 104L), itemIds(chatsCtx)) + } + + @Test + fun beforePageLoadedForAnotherChatIsNotAddedToOpenedChat() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val anotherChat = groupChat(groupId = 1, itemIds = listOf(201, 202)) + processLoadedChat(chatsCtx, anotherChat, NavigationInfo(), ChatPagination.Before(101, 2), openAroundItemId = null) + assertEquals(listOf(101L, 102L), itemIds(chatsCtx)) + } + + @Test + fun aroundPageLoadedForAnotherChatIsNotAddedToOpenedChat() = runBlocking { + val (chatsCtx, _) = openChatWithItems(listOf(101, 102)) + val anotherChat = groupChat(groupId = 1, itemIds = listOf(201, 202)) + processLoadedChat(chatsCtx, anotherChat, NavigationInfo(), ChatPagination.Around(101, 2), openAroundItemId = null) + assertEquals(listOf(101L, 102L), itemIds(chatsCtx)) + } +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index a904d2859b..b3a8407839 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.1-beta.1 -android.version_code=374 +android.version_name=7.1-beta.2 +android.version_code=377 -desktop.version_name=7.1-beta.1 -desktop.version_code=158 +desktop.version_name=7.1-beta.2 +desktop.version_code=160 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/blog/20260819-simplex-chat-crowdfunding.md b/blog/20260819-simplex-chat-crowdfunding.md index b0114d73da..93bf90fc8c 100644 --- a/blog/20260819-simplex-chat-crowdfunding.md +++ b/blog/20260819-simplex-chat-crowdfunding.md @@ -53,6 +53,7 @@ The web solved this by letting anyone build any experience on one open platform. SimpleX Chat is the company that builds SimpleX Network, and now you can invest from $100 and get a stake in the company. If you invest $500 or more, you receive [a public SimpleX name](./20260722-simplex-public-names.md) on SimpleX Network: +- for 7 years, if you invest by September 22, - for 5 years for early bird investors, - for 3 years after that. diff --git a/blog/20260908-nick-rogers-why-i-backed-simplex-chat.md b/blog/20260908-nick-rogers-why-i-backed-simplex-chat.md new file mode 100644 index 0000000000..a29b1bddb5 --- /dev/null +++ b/blog/20260908-nick-rogers-why-i-backed-simplex-chat.md @@ -0,0 +1,81 @@ +--- +layout: layouts/article.html +title: "Nick Rogers: Why I Backed SimpleX Chat in 2022, and Why I Doubled Down" +date: 2026-09-08 +preview: "Blog post by Nick Rogers, early investor and advisor of SimpleX Chat, founder of Fieldwork, former CPO & CTO of Stream." +permalink: "/blog/20260908-nick-rogers-why-i-backed-simplex-chat.html" +--- + +# Why I Backed SimpleX Chat in 2022, and Why I Doubled Down + +**Published:** Sep 08, 2026 + +*By [Nick Rogers](https://x.com/nickwd), Early Investor and Advisor, SimpleX Chat | Founder, Fieldwork | former CPO & CTO, Stream (Wagestream)* + +--- + +When I first met Evgeny at Wagestream (now Stream) back in 2020, he was one of the most unique people I had ever encountered in technology. + +He was fiercely, relentlessly honest. In a tech industry where corporate diplomacy usually trumps candour, that honesty struck me as genuine bravery. Evgeny was never afraid to speak truth to authority, and he held an uncompromisingly high bar for technical truth. + +In late 2021, when he decided to step down as VP of Engineering to build a brand new messaging protocol from scratch, I took over as VP of Engineering. + +I knew how massive the shoes were that I had to fill. But more than that, I knew that whatever Evgeny set out to build next, he would pursue with absolute conviction. + +Most people in software already know Evgeny without realizing it. He is the creator of **Ajv** (Another JSON Schema Validator), one of the most widely used open source libraries in the JavaScript ecosystem, downloaded over a billion times each month by virtually every major technology company on earth. When someone has already built foundational infrastructure that the entire web secretly depends on, you take notice when they turn their attention to a new problem. + +### The 2022 Bet: Backing a Protocol Before It Had Users + +When Evgeny first pitched me on **SimpleX Chat**, his ambition was enormous: build an alternative to WhatsApp and Telegram that fundamentally eliminated user identifiers. + +No phone numbers. No email addresses. No usernames. Not even a persistent public key or random user ID. + +My first thought was that competing with WhatsApp was wildly ambitious. But as an engineering leader, I also knew there was a massive, structural gap in the market. + +People assume end-to-end encryption solves privacy. It does not. WhatsApp, Telegram, Signal, and Matrix still rely on centralized account registries or user identifiers. Even when the message payload is encrypted, the platforms still collect, analyze, and classify the metadata: who you are, who you talk to, at what time, and from where. Between on-device classification, advertising networks, and platform-level censorship, modern messaging is fundamentally broken at the protocol layer. + +When I wrote one of the first angel checks into SimpleX's SEIS round in early 2022 (at a $5M valuation), there were no mobile app store rankings. There was barely a user base. It was essentially a Haskell terminal prototype on GitHub and Evgeny's mathematical conviction that you could route messages over isolated, unidirectional queues without ever tracking who was talking to whom. + +I invested because I believed in the architectural insight, and because I knew Evgeny was stubborn enough to work on this until it succeeded. + +### Four Years Later: The Inflection Point + +Over the last four years, as an investor and advisor, I have watched that initial command-line prototype grow into a global network: + +* **Almost 500,000 Monthly Active Users** (up from zero when I invested). +* **3 Million+ App Downloads** and **over 20 Million messages per day**. +* **Zero paid marketing spend.** + +SimpleX has reached this size because it started with the people who need uncompromising privacy the most: journalists, whistleblowers, developers, cryptographers, and sovereign communities. + +More importantly, it has evolved from a 1:1 private messenger into a platform for **sovereign publishing and broadcast channels**. Platforms like Telegram banned tens of millions of channels in 2025 alone, proving to creators and communities that they do not own their audience on centralized platforms. On SimpleX channels, publishers cannot be revoked, subscribers cannot be tracked, and content cannot be taken down centrally. + +### Why the Commercial Model Works: Scale, Low ARPU, and Organic Demand + +A common criticism of privacy tools is that they make great philosophical statements but terrible businesses. + +I take the opposite view for two reasons: + +1. **Unsolicited willingness to pay:** Before SimpleX even introduced paid commercial tiers, users voluntarily gave over **$650,000 in unsolicited donations**. When a community gives you hundreds of thousands of dollars purely to support the software, you have a level of organic product love and willingness to pay that most consumer startups spend tens of millions of venture dollars trying to manufacture. +2. **Viral dynamics and low ARPU:** Messaging apps are inherently viral networks. Once a protocol achieves scale, building a high-margin, profitable business is straightforward even at very low average revenue per user (ARPU). + +SimpleX is not monetizing through surveillance advertising. It is monetizing through core infrastructure utilities: +* **SimpleX Domains (`.simplex` public names launching Dec 12):** Memorable names for public channels and contact addresses, providing recurring utility revenue. +* **Paid Supporter Badges and Dedicated Relays:** High-throughput infrastructure for power users and enterprise teams. +* **Enterprise Messaging:** Allowing businesses to talk to customers via their websites, without any privacy compromises of existing solutions like Intercom or Drift (especially important for sectors that require anonymity by law). + +### Why I Doubled Down, and Why You Should Join the Wefunder Round + +Messaging is the most fundamental layer of human communication on the internet. It should look more like email: an open, decentralized protocol that nobody owns, rather than a walled garden controlled by Big Tech. + +When Evgeny opened an insider round in mid-2026, I immediately wrote another check. And when he decided to take this round to **Wefunder** ([wefunder.com/simplexchat](https://wefunder.com/simplexchat?utm_source=nr_post)), I was proud to represent the community of investors backing him. + +In startup investing, you back two things: **the founder and the TAM**. + +The market for private, sovereign communications is enormous. And Evgeny is the rarest kind of founder: technically brilliant, relentlessly honest, and stubborn enough to see an audacious mission through to the end. + +If you believe that the future of communication should belong to individuals rather than centralized platforms, I encourage you to join us as an investor. + +👉 **Invest in SimpleX Chat on Wefunder:** [wefunder.com/simplexchat](https://wefunder.com/simplexchat?utm_source=nr_post) + +*(Note: Anyone investing $500 or more before September 22 secures their `.simplex` public name for 7 years prior to the public launch.)* diff --git a/cabal.project b/cabal.project index 2aa5d15026..9c55ad7677 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1876357c7f64fc00d9d031084c5e78e844b27c34 + tag: 2d4b40e10475fd2d09c76c590df89f384ac45c85 source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 4217b0e8fa..b0830995dd 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.11.1", + "version": "0.11.2", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 51311cfb5e..b578194679 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.1", + "version": "7.1.0-beta.2", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.1", + "@simplex-chat/types": "^0.11.2", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 727e5164d5..3f10023f88 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.1'; +const RELEASE_TAG = 'v7.1.0-beta.2'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 091488e2af..a335c71d03 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.1.0b1" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.1" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b2" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.2" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index ca55a08383..0c8396e5db 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,10 +38,10 @@ - + https://simplex.chat/blog/20260722-simplex-public-names.html -

New in v7.0-v7.0.1.

+

New in v7.0-v7.0.2.

SimpleX public names for channels and businesses (BETA).

Better channels: