diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift index 1ec46816f5..4b9169c72a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift @@ -23,6 +23,7 @@ struct ComposeFileView: View { .foregroundColor(Color(uiColor: .tertiaryLabel)) .padding(.leading, 4) Text(fileName) + .lineLimit(1) Spacer() if cancelEnabled { Button { cancelFile() } label: { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 427a600627..9047eaf84b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -163,10 +163,13 @@ struct ContextProfilePickerView: View { } label: { HStack { ProfileImage(imageStr: user.image, size: 38) - Text(user.chatViewName) - .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(user.chatViewName) + .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular) + .foregroundColor(theme.colors.onBackground), + user.profile.localBadge + ) + .lineLimit(1) Spacer() diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index 27935768e3..aa94f5b346 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -24,26 +24,24 @@ struct ChannelRelaysView: View { var body: some View { List { relaysList() - // TODO [relays] re-enable when relay management ships - // if groupInfo.isOwner { - // Section { - // Button { - // showAddRelay = true - // } label: { - // Label("Add relay", systemImage: "plus") - // } - // } - // } + if groupInfo.isOwner { + Section { + Button { + showAddRelay = true + } label: { + Label("Add relay", systemImage: "plus") + } + } + } + } + .sheet(isPresented: $showAddRelay) { + // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // regardless of relayStatus, so all current rows must be excluded from the add list. + let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) + AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { + Task { await chatModel.loadGroupMembers(groupInfo) } + } } - // TODO [relays] re-enable when relay management ships - // .sheet(isPresented: $showAddRelay) { - // // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays - // // regardless of relayStatus, so all current rows must be excluded from the add list. - // let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) - // AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { - // Task { await chatModel.loadGroupMembers(groupInfo) } - // } - // } .onAppear { Task { await chatModel.loadGroupMembers(groupInfo) @@ -82,20 +80,18 @@ struct ChannelRelaysView: View { : subscriberRelayStatusText(member.wrapped) relayMemberRow(member.wrapped, statusText: statusText) } - // TODO [relays] re-enable when relay management ships - // if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) { - // link.swipeActions(edge: .trailing) { - // Button { - // showRemoveMemberAlert(groupInfo, member.wrapped) - // } label: { - // Label("Remove relay", systemImage: "trash") - // } - // .tint(.red) - // } - // } else { - // link - // } - link + if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) { + link.swipeActions(edge: .trailing) { + Button { + showRemoveMemberAlert(groupInfo, member.wrapped) + } label: { + Label("Remove relay", systemImage: "trash") + } + .tint(.red) + } + } else { + link + } } } footer: { Text("Chat relays forward messages to channel subscribers.") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 28693e8d8a..2f37e3d822 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -633,8 +633,7 @@ struct GroupMemberInfoView: View { blockForAllButton(mem) } } - // TODO [relays] re-enable when relay management ships - if canRemove && mem.memberRole != .relay { + if canRemove { if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) { removeMemberButton(mem) } else if mem.memberRole != .relay { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c5dc039d8d..0bda968587 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2081,7 +2081,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2106,7 +2106,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2131,7 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2156,7 +2156,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2173,11 +2173,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2193,11 +2193,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2218,7 +2218,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2233,7 +2233,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2255,7 +2255,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2270,7 +2270,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2292,7 +2292,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2318,7 +2318,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2343,7 +2343,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2370,7 +2370,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2397,7 +2397,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2412,7 +2412,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2431,7 +2431,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2446,7 +2446,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 35e92e2cd2..54986e1815 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2289,7 +2289,7 @@ object ChatController { return when { r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup r is API.Error -> { - AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "$r.err") + AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}") null } else -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index ce023e83c9..b7be49055e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -146,9 +146,10 @@ fun ComposeContextProfilePickerView( ) { ProfileImage(size = USER_ROW_AVATAR_SIZE, image = user.image) TextIconSpaced(false) - Text( + NameWithBadge( user.chatViewName, - modifier = Modifier.align(Alignment.CenterVertically), + user.profile.localBadge, + Modifier.align(Alignment.CenterVertically), fontWeight = if (selectedUser.value.userId == user.userId && !incognitoDefault) FontWeight.Medium else FontWeight.Normal ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt index 7ab7963547..26e069c739 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt @@ -33,8 +33,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo .size(36.dp), tint = if (isInDarkTheme()) FileDark else FileLight ) - Text(fileName) - Spacer(Modifier.weight(1f)) + Text(fileName, maxLines = 1, modifier = Modifier.weight(1f)) if (cancelEnabled) { IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) { Icon( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 3ae7f9d55b..21290b99b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -330,7 +330,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: val imagesPreview = ArrayList() uris.forEach { uri -> var bitmap: ImageBitmap? - when { + val uploadContent: UploadContent? = when { isImage(uri) -> { // Image val drawable = getDrawableFromUri(uri) @@ -340,16 +340,19 @@ suspend fun MutableState.processPickedMedia(uris: List, text: // It's a gif or webp val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { - content.add(UploadContent.AnimatedImage(uri)) + UploadContent.AnimatedImage(uri) } else { bitmap = null AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) ) + null } } else if (bitmap != null) { - content.add(UploadContent.SimpleImage(uri)) + UploadContent.SimpleImage(uri) + } else { + null } } else -> { @@ -357,11 +360,22 @@ suspend fun MutableState.processPickedMedia(uris: List, text: val res = getBitmapFromVideo(uri, withAlertOnException = true) bitmap = res.preview val durationMs = res.duration - content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) + UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0) } } - if (bitmap != null) { + // content and imagesPreview must stay index-aligned and equal-length: both consumers + // (ComposeImageView and sendMessageAsync) cross-index one list by the other's index. + // Only pair them when a preview bitmap exists; otherwise skip the media entirely. + if (bitmap != null && uploadContent != null) { + content.add(uploadContent) imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + } else if (uploadContent is UploadContent.Video && !AlertManager.shared.hasAlertsShown()) { + // A corrupted/undecodable video can yield a null preview frame without throwing, so + // getBitmapFromVideo shows no alert. Skip it (other picked media still send) and tell + // the user instead of dropping it silently. hasAlertsShown guards against stacking the + // alert across multiple bad items and against duplicating the one already shown on the + // exception path. Image decode failures are already surfaced by getBitmapFromUri above. + showVideoDecodingException() } } if (imagesPreview.isNotEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index d99e16d15f..d7ffde71c8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -87,8 +87,6 @@ private fun ChannelRelaysLayout( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - // TODO [relays] re-enable when relay management ships - /* if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) { DefaultDropdownMenu(showMenu) { ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { @@ -97,7 +95,6 @@ private fun ChannelRelaysLayout( }) } } - */ val statusText = if (groupInfo.isOwner) { ownerRelayStatusText(member, groupRelays) } else { @@ -109,8 +106,6 @@ private fun ChannelRelaysLayout( } SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages)) } - // TODO [relays] re-enable when relay management ships - /* if (groupInfo.isOwner) { SectionView { SectionItemView(click = { @@ -139,7 +134,6 @@ private fun ChannelRelaysLayout( } } } - */ SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 2fad3c73f7..8739def3d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -421,8 +421,7 @@ fun GroupMemberInfoLayout( @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) - // TODO [relays] re-enable when relay management ships - val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay + val canRemove = member.canBeRemoved(groupInfo) if (canBlockForAll || canRemove) { SectionDividerSpaced() SectionView { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 7dfb52063e..ca1528a3ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -254,9 +254,9 @@ suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentT suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { // groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) - val currentMembers = chatModel.groupMembers.value + val currentMembersById = chatModel.groupMembers.value.associateBy { it.id } val newMembers = groupMembers.map { newMember -> - val currentMember = currentMembers.find { it.id == newMember.id } + val currentMember = currentMembersById[newMember.id] val currentMemberStats = currentMember?.activeConn?.connectionStats val newMemberConn = newMember.activeConn if (currentMemberStats != null && newMemberConn != null && newMemberConn.connectionStats == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 39c4cb0b7f..fd6b27482d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -413,12 +413,12 @@ private fun MutableState.FinishedView(chatDeletion: Boolean) } ) {} } - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) if (chatDeletion) { ProgressView() } } + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) } @Composable diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index a2b63a5810..61c744bf86 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.4 -android.version_code=353 +android.version_name=6.5.5 +android.version_code=355 android.bundle=false -desktop.version_name=6.5.4 -desktop.version_code=145 +desktop.version_name=6.5.5 +desktop.version_code=146 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/cabal.project b/cabal.project index 1768801429..1c7d56ae17 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: e250a9ec9d67143db674bc18edb269b64ec1d397 + tag: 8e0b8de529bcfee3d9c9a8c28b3a039a4644e9ae 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 ad7ab04462..756e181307 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.8.0", + "version": "0.9.0", "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 0dff1f7f1d..40ef106103 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.4", + "version": "6.5.5", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.8.0", + "@simplex-chat/types": "^0.9.0", "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 72761a1ac5..4fad0f45a9 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 = 'v6.5.4'; +const RELEASE_TAG = 'v6.5.5'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 2ae4ce941e..77acd0e8b3 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__ = "6.5.4" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "6.5.4" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "6.5.5" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.5" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/plans/2026-06-04-fix-corrupted-video-upload-error.md b/plans/2026-06-04-fix-corrupted-video-upload-error.md new file mode 100644 index 0000000000..e88e781a5d --- /dev/null +++ b/plans/2026-06-04-fix-corrupted-video-upload-error.md @@ -0,0 +1,100 @@ +# Fix: IndexOutOfBoundsException when uploading media with an undecodable preview + +## Symptom + +``` +java.lang.IndexOutOfBoundsException: Index 6 out of bounds for length 6 + at java.util.ArrayList.get(ArrayList.java:434) + at chat.simplex.common.views.chat.ComposeViewKt.ComposeView$sendMessageAsync(ComposeView.kt:827) + ... +``` + +The crash fires when sending a batch of picked media (e.g. 7 items) in which at least +one item produces no preview bitmap — most commonly a corrupted or unusual video whose +first frame cannot be extracted. + +## Root cause + +`ComposePreview.MediaPreview` carries two parallel lists that are assumed to be +**equal-length and index-aligned**: + +```kotlin +class MediaPreview(val images: List, val content: List) +``` + +Both consumers cross-index one list by the other's index, so the invariant is load-bearing: + +- `ComposeImageView` (preview row) iterates `media.images` and reads `media.content[index]`. +- `ComposeView.sendMessageAsync` iterates `preview.content` and reads `preview.images[index]` + (the `MCImage` / `MCVideo` preview string). This is the crash site. + +`processPickedMedia` built the two lists out of step: + +- `imagesPreview` was appended **only when `bitmap != null`**. +- `content` was appended **unconditionally** for videos, and for animated images that + passed the size check — regardless of whether a preview bitmap was produced. + +`getBitmapFromVideo` returns `PreviewAndDuration(null, …)` whenever Android's +`MediaMetadataRetriever` cannot extract a frame, **even when no exception is thrown** +(`Utils.android.kt:351`). So a single undecodable video appends to `content` but not to +`images`, leaving `content.size == images.size + 1`. Iterating `content` then indexes +`images[lastIndex+1]` → `Index N out of bounds for length N`. + +This is a pre-existing bug in the shared media picker; it is unrelated to any in-flight +feature work and reproduces on both Android and Desktop (both use the `commonMain` +`processPickedMedia`). + +## Fix + +Keep `content` and `imagesPreview` strictly paired at the source. The `when` now yields an +`UploadContent?` instead of mutating `content` inside its branches, and both lists are +appended together, gated on a non-null preview bitmap: + +```kotlin +if (bitmap != null && uploadContent != null) { + content.add(uploadContent) + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) +} +``` + +Each iteration now adds exactly zero or one entry to **both** lists, so the +equal-length / index-aligned invariant holds by construction. + +### Behavior change + +Media that yields no decodable preview frame is now **skipped** rather than enqueued. +Previously such a video crashed the send; now only the bad item is dropped and the rest of +the picked batch sends normally (the loop evaluates each URI independently). + +The skip is **not silent**. A skipped video shows `showVideoDecodingException()`, gated on +`AlertManager.hasAlertsShown()` so the alert neither stacks across several bad items in one +batch nor duplicates the one `getBitmapFromVideo` already shows on its exception path. The +genuinely silent gap this closes is the video path that returns a null frame **without** +throwing (Android `getFrameAtTime` returns null; Desktop snapshot times out) — that path +previously produced no alert and then crashed on send. + +Image decode failures need no new alert here: `getBitmapFromUri` is already called for every +image (animated or not) with `withAlertOnException = !hasAlertsShown()`, so a null image +bitmap is surfaced before this point. Only the video null-frame case lacked any notice. + +## Why this approach + +- **Fixes the invariant at its origin** rather than papering over it at the two read + sites. Guarding `images[index]` in `sendMessageAsync` would stop the crash but leave the + preview row (`ComposeImageView`) silently mismatched and the actual media set ambiguous. +- **Minimal, surgical diff** confined to `processPickedMedia`; no API/type changes, no new + placeholder assets, no touch to the read sites. +- **Cross-platform by construction**: the change lives in `commonMain`, so Android and + Desktop are both covered. iOS has a separate Swift compose implementation and is out of + scope for this fix. + +## Other `MediaPreview` construction sites (verified aligned) + +- `cs.preview` → single-element `listOf(mc.image)` / `listOf(content)` (edit path): aligned. +- `constructFailedMessage` takes `last()` of each list: aligned if the input was aligned. + +## Test notes + +Manual repro: pick a multi-item batch including a corrupted/zero-frame video and send. +- Before: `IndexOutOfBoundsException` on send. +- After: the undecodable item is dropped; remaining media sends normally. diff --git a/plans/2026-06-09-perf-group-members-merge-on2.md b/plans/2026-06-09-perf-group-members-merge-on2.md new file mode 100644 index 0000000000..3000103b05 --- /dev/null +++ b/plans/2026-06-09-perf-group-members-merge-on2.md @@ -0,0 +1,95 @@ +# Perf — index member merge in `setGroupMembers` to O(n) + +**PR:** #7061 (`nd/group-members-merge-on2`) +**Scope:** client-only (multiplatform: android + desktop). One-line change, no behavioral change. +**File:** `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt:254` + +## Root cause (verified) + +`setGroupMembers` is the shared loader for the in-memory member list. After fetching +members from core (`apiListMembers`) it merges the freshly-loaded list with the +connection stats already held in memory, so an in-flight `connectionStats` isn't lost +when the list is reloaded — `ChatListNavLinkView.kt:257-267`: + +```kotlin +val currentMembers = chatModel.groupMembers.value +val newMembers = groupMembers.map { newMember -> + val currentMember = currentMembers.find { it.id == newMember.id } // O(n) scan, inside an O(n) map → O(n²) + ... +} +``` + +Two compounding costs: + +1. **O(n²) merge.** `currentMembers.find { it.id == newMember.id }` is a linear scan + run once per new member — `n` lookups × `n` scan = O(n²). +2. **~n² String allocations + GC pressure.** `GroupMember.id` is a *computed* property, + not a stored field — `ChatModel.kt:2424`: + + ```kotlin + val id: String get() = "#$groupId @$groupMemberId" + ``` + + Every `it.id` and `newMember.id` access allocates a fresh `String`. The nested + `find` evaluates `it.id` for (worst case) every current member on every iteration, + so the merge allocates on the order of n² short-lived strings, each compared by + value. In groups with thousands of members this is a visible main-thread lag spike. + +## Worst case observed + +`setGroupMembers` reloads `chatModel.groupMembers` (and runs the merge) whenever the +member list is (re)loaded while members are already in memory. The most noticeable +case: the **Chats with members** support-chat modal (`MemberSupportView`) reloads the +whole list via `LaunchedEffect(Unit) { setGroupMembers(...) }` every time it (re)enters +composition — e.g. after reading and closing a member's support chat — so it pays the +full O(n²) merge and produces a lag spike on close in large groups +(`MemberSupportView.kt:44`). + +## The fix (minimal — one change) + +Index the current members by id **once**, then look up in O(1): + +```kotlin +val currentMembersById = chatModel.groupMembers.value.associateBy { it.id } +val newMembers = groupMembers.map { newMember -> + val currentMember = currentMembersById[newMember.id] + ... +} +``` + +- `associateBy { it.id }` builds the index in a single O(n) pass; each subsequent + lookup is O(1). Total merge cost drops from O(n²) to O(n). +- String allocations drop from ~n² to ~2n (one `it.id` per current member while + building the map, one `newMember.id` per lookup). + +## Why it's safe (no behavioral change) + +- **Member ids are unique** — `id = "#$groupId @$groupMemberId"` is unique per member + within a group, and `setGroupMembers` always works within a single `groupInfo`. So + `associateBy { it.id }` cannot collide; `map[id]` returns exactly what + `find { it.id == id }` returned. +- Same result set, same merged `GroupMember` objects, same order of `newMembers` + (the `map` over `groupMembers` is unchanged). Only the lookup strategy changes. +- Everything downstream of the merge is untouched — `groupMembersIndexes`, + `groupMembers.value`, `membersLoaded`, `populateGroupMembersIndexes()` + (`ChatListNavLinkView.kt:268-271`). + +## Also sped up (same shared loader) + +`setGroupMembers` is the common in-memory member loader, called from several screens; +all of them get the same speedup in large groups (identical results, just O(n)): + +- Group member list / member management — `GroupChatInfoView` (`:121, :1250, :1267`) +- @-mention autocomplete — `GroupMentions` (`:119, :134`) +- Channel relays — `ChannelRelaysView` (`:38, :124`) +- Add members — `AddGroupView` (`:52`) +- The group chat's member load on open — `ChatView` (multiple sites) +- Chats with members — `MemberSupportView` (`:45, :67`) + +## Verification + +- Reasoned: ids unique within a group → `associateBy`/lookup is semantically identical + to the linear `find`. No caller observes a difference. +- Manual: open **Chats with members** in a large group, read and close a member's + support chat repeatedly — the lag spike on close should be gone. Member list, + @-mentions, relays, and add-members screens should remain identical, just faster. diff --git a/plans/2026-06-12-fix-migrate-text-overlap.md b/plans/2026-06-12-fix-migrate-text-overlap.md new file mode 100644 index 0000000000..1bb1d3a5c6 --- /dev/null +++ b/plans/2026-06-12-fix-migrate-text-overlap.md @@ -0,0 +1,71 @@ +# Fix overlapping warning texts after finalizing migration + +Branch: `nd/fix-migrate-text` · regression from PR [#6777](https://github.com/simplex-chat/simplex-chat/pull/6777) (`df5ea3d46`, new settings section design). + +## 1. Problem statement + +On the "Migrate device" screen (Android and desktop), after tapping **Finalize migration** the finished state renders broken: the two warning texts — "You **must not** use the same database on two devices." and "**Please note**: using the same database on two devices will break the decryption of messages…" — are painted on top of each other and on top of the "Migration complete" section card, directly under the section header. + +Reproduced on desktop with default settings. The screen immediately before (`LinkShownView`, with the QR code) renders correctly. + +## 2. Solution summary + +Move the two `SectionTextFooter` calls in `FinishedView` out of the `Box` and place them after it, so they render as sequential children of the screen's scroll `Column` — the same placement `LinkShownView` already uses for its footers. + +```diff + } +- SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) +- SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) + if (chatDeletion) { + ProgressView() + } + } ++ SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) ++ SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) + } +``` + +Total diff: 1 file, 2 lines moved (+2 / −2 at different indentation). + +## 3. Root cause + +PR #6777 added card chrome to `SectionView` and, in a sub-commit ("Migrate views: move all SectionTextFooter / SectionSpacer out of SectionView lambdas"), moved footers out of the card lambdas so they read as captions below the cards. In `FinishedView` (`MigrateFromDevice.kt`) the footers were moved out of the `SectionView` — but left **inside the wrapping `Box`**: + +```kotlin +Box { + SectionView(stringResource(MR.strings.migrate_from_device_migration_complete)) { + // "Start chat" / "Delete database" buttons + } + SectionTextFooter(…you_must_not_start_database_on_two_device…) // Box child 2 + SectionTextFooter(…using_on_two_device_breaks_encryption…) // Box child 3 + if (chatDeletion) { + ProgressView() // Box child 4 (overlay) + } +} +``` + +That `Box` exists for exactly one reason: to overlay `ProgressView` (a fullscreen-centered spinner) over the section while the chat database is being deleted. `Box` stacks its children at `TopStart`, so both footers render at the Box's top-left corner — over the card's top edge and over each other. This is the only migration sub-view where the refactor produced this shape: the other `Box`-wrapped states keep all flow content inside one child (a `SectionView` or an inner `Column`), and `LinkShownView` has no `Box` at all. + +`FinishedView` is composed inside `ColumnWithScrollBar` (via `SectionByState`), so composables emitted at the function's top level land in the scroll `Column` and stack vertically — which is where the footers belong. + +## 4. The fix in detail, and why this shape + +Three candidate fixes were compared: + +- **Move the 2 footer lines after the `Box`** (chosen). Smallest possible diff, zero re-indentation. Footers become siblings of the Box in the scroll `Column`, identical to the working `LinkShownView` pattern in the same file. `ProgressView` keeps its overlay semantics unchanged (same as `DatabaseInitView`, `ArchivingView`, `LinkCreationView`). Only behavioural delta beyond the bug fix: during the transient `chatDeletion` spinner, the overlay centers over the card rather than card + footers — matching every other migration sub-view. +- **Wrap card + footers in a `Column` inside the Box.** Behaviorally near-identical, but ~40 lines of indentation churn and a layout shape no sibling view uses. Rejected: larger diff, no benefit. +- **Also hoist `ProgressView` out of the Box.** Changes overlay semantics (spinner would flow below content instead of over it). Rejected: touches behavior the bug report doesn't concern. + +Regression risk: the change is placement-only — no logic, no state, no measurement changes. The new arrangement is the proven pattern of the adjacent view. + +## 5. Scope verification — no other instances of the bug class + +The class ("flow content as direct children of an overlay `Box`") was searched for across all Kotlin source sets (`commonMain`, `androidMain`, `desktopMain`, `android`, `desktop`) with three complementary structural scans: + +1. Every `Box` block with ≥2 stacking flow children (section views, footers, spacers, settings items): **only** `FinishedView`. +2. All 384 footer/spacer call sites classified by nearest enclosing block: the only ones directly inside a `Box` are the two fixed lines. +3. All ~25 composable functions that emit footers at function top level (placement decided by caller): no caller invokes them inside a `Box`. + +iOS is structurally immune: SwiftUI footers are part of `Section { } footer: { }` inside a `List`; `MigrateFromDevice.swift`'s `finishedView` was verified correct. + +Related but distinct (not fixed here): 10 `SectionTextFooter` calls app-wide still sit *inside* `SectionView` card lambdas (6 in migration views, plus `LinkAMobileView`, `ConnectMobileView`, 2 in `NetworkAndServers`), rendering inside the white card instead of as captions below it. Cosmetic placement inconsistency with #6777's stated pattern, no overlap — left for a separate change if desired. diff --git a/plans/2026-06-15-fix-cli-outdated-help.md b/plans/2026-06-15-fix-cli-outdated-help.md new file mode 100644 index 0000000000..e0105ef5bb --- /dev/null +++ b/plans/2026-06-15-fix-cli-outdated-help.md @@ -0,0 +1,42 @@ +# Remove CLI help entries for long-removed commands + +Branch: `nd/fix-cli-outdated-help` · file `src/Simplex/Chat/Help.hs`. + +## 1. Problem statement + +Typing `/get stats` in the terminal CLI does nothing useful — it is documented in `/help` but no parser accepts it, so it fails to parse. Investigation found this is not isolated: four documented commands no longer exist in the parser. + +## 2. Solution summary + +Remove the four stale entries (five lines, including one continuation note) from `Help.hs`: + +- `/pq @ on/off` + its "(both have to enable…)" note — `contactsHelpInfo` +- `/pq on/off` — `settingsInfo` +- `/get stats` — `settingsInfo` +- `/reset stats` — `settingsInfo` + +The stats pair were the tail of `settingsInfo`, so the now-orphaned trailing comma on the preceding `/(un)mute #` element is also dropped to keep the list literal valid. + +No replacement text is added: PQ has no command (it is automatic), and the stats functionality has no argument-compatible successor (see §4). + +## 3. Root cause + +Both removals were core changes that deleted parser, handler, and command constructor but left `Help.hs` untouched: + +- **`/pq` (both forms)** — commit `756779186` "core: enable PQ encryption for contacts (#4049)", 2024-04-22. It removed the parsers `"/pq @" *> (SetContactPQ …)` and `"/pq " *> (APISetPQEncryption …)`; post-quantum encryption for contacts became automatic, so the manual toggle was obsolete. `SetContactPQ` and `APISetPQEncryption` no longer exist in `src/`. +- **`/get stats` / `/reset stats`** — commit `5907d8bd0` "core: remove legacy agent stats (#4375)", 2024-07-01. It removed the parsers `"/get stats" $> GetAgentStats` and `"/reset stats" $> ResetAgentStats`, their handlers, the `GetAgentStats`/`ResetAgentStats` constructors in `Controller.hs`, and the `View.hs` rendering — but its diff touched `Chat.hs`, `Controller.hs`, `View.hs`, `cabal.project`, `sha256map.nix`, not `Help.hs`. + +In both cases the help text became a promise the binary could no longer keep. + +## 4. Scope verification — no other stale entries, no replacements documented + +All 120 commands documented across every section of `Help.hs` were extracted and matched against the parser string literals in `Library/Commands.hs` (`chatCommandP`). Every entry resolves to a live parser except the four above. ~10 entries that a naive prefix match flagged were manually confirmed valid: incognito-suffix forms parsed by `incognitoP` (`/accept incognito`, `/connect incognito`, `/simplex incognito`), usage examples (`/file bob ./photo.jpg`, `/group team`), and inline sub-alternatives (`/start remote host new`, `/stop remote host new`, `/switch remote host local`, `/chats all`). + +Why no replacement text: + +- **PQ** — there is no command; encryption is negotiated automatically. Documenting nothing is correct. +- **Stats** — the nearest live commands are `/get servers summary ` and `/reset servers stats`, but they require a `userId` argument and return the agent servers summary, not the old argument-less usage statistics. They were never in CLI help; adding them is a separate documentation enhancement, deliberately out of scope for a "remove what no longer exists" fix. + +## 5. Why this shape + +Pure deletion of dead documentation — no behavioral change, smallest diff that makes `/help` truthful. Comma handling is the only subtlety: the `/pq @` and `/pq on/off` removals sit before comma-bearing neighbors (a `""` separator and `/network` respectively) and need no adjustment; the `/get stats` + `/reset stats` removal makes `/(un)mute #` the last `settingsInfo` element, so its trailing comma is removed to avoid a dangling-comma parse error before `]`. diff --git a/plans/2026-06-15-fix-file-upload-long-name.md b/plans/2026-06-15-fix-file-upload-long-name.md new file mode 100644 index 0000000000..f0917783bd --- /dev/null +++ b/plans/2026-06-15-fix-file-upload-long-name.md @@ -0,0 +1,77 @@ +# Fix: long file name hides the close icon in the compose file preview + +Date: 2026-06-15 +Branch: `nd/fix-file-upload-with-long-name` +Platforms affected: Android, Desktop, iOS + +## Problem + +When a file is attached for sending, the compose area shows a preview row with the +file icon, the file name, and a close (X) icon to cancel/remove the file before +sending. If the file name is long, the close icon is not shown, so the user cannot +dismiss the attachment. + +## Cause + +The bug is the same layout defect on both codebases: the file-name text is +unconstrained, so a long name consumes all horizontal space and squeezes the +trailing close button to zero width. + +### Android / Desktop — `ComposeFileView.kt` + +The row was laid out as: + +``` +Icon(fixed) | Text(fileName) | Spacer(weight 1f) | IconButton(close) + ^ unweighted, no maxLines +``` + +In a Compose `Row`, unweighted children are measured first and take the remaining +width before weighted children get anything. The unweighted `Text` therefore grabbed +the whole remaining width on a long name, leaving the weighted `Spacer` — and the +`IconButton` after it — with ~0 width. The flexible element was the `Spacer`, but a +`Spacer` can only distribute the space the rigid `Text` did not already eat. + +### iOS — `ComposeFileView.swift` + +``` +Image(fixed) | Text(fileName) | Spacer() | Button(close) + ^ no lineLimit +``` + +A `Text` with no `lineLimit` reports its full single-line ideal width and refuses to +truncate, so a long name collapses the `Spacer` and pushes the `Button` past the +`.frame(maxWidth: .infinity)` edge, off-screen. + +## Fix + +Make the file name the element that yields space and let it truncate, so the +fixed-size close control's space is always reserved. + +- **Kotlin:** give the `Text` the `weight(1f)` (instead of the `Spacer`) and + `maxLines = 1`, and drop the now-redundant `Spacer`. This matches the existing + idiom — `ComposeImageView` puts `weight(1f)` on its content, and `CIFileView` + caps file-name text with `maxLines = 1`. +- **Swift:** add `.lineLimit(1)` to the `Text`, so it truncates instead of + overflowing, matching how file names are shown elsewhere on iOS. + +## Why this is the right fix (not a workaround) + +`ComposeFileView` was the only compose preview that gave the weight to a `Spacer` +rather than to its content; every sibling preview (`ComposeImageView`, +`ContextItemView`) reserves space for the trailing close control by weighting the +content. The change brings the file preview in line with the established pattern +rather than adding a special case. It is purely structural — no behavior changes +beyond layout. + +## Scope / risk + +- One-spot edit per file; no API or behavior change. +- Android and Desktop share the Kotlin file, so both are fixed together; iOS is the + separate Swift file. +- No string/translation keys touched. + +## Verification + +- Visual: attach a file with a very long name on Android, Desktop, and iOS; confirm + the name truncates and the close (X) icon stays visible and tappable. diff --git a/plans/2026-06-17-fix-group-garbled-error.md b/plans/2026-06-17-fix-group-garbled-error.md new file mode 100644 index 0000000000..c660d13a48 --- /dev/null +++ b/plans/2026-06-17-fix-group-garbled-error.md @@ -0,0 +1,27 @@ +# Fix garbled error when saving group profile (member admission) + +## Problem + +Saving a group profile change — e.g. enabling member admission (Review = "All") from Group preferences → Member admission — can fail with an unreadable alert: + +``` +chat.simplex.common.model.API$Error@3ea295c.err +``` + +The user sees an object reference instead of the actual error, so there is no way to tell what went wrong. + +## Cause + +In `apiUpdateGroup` the `API.Error` branch builds the alert message with `"$r.err"` (`SimpleXAPI.kt:2292`). In a Kotlin string template `"$r.err"` interpolates `r.toString()` — and `API.Error` has no custom `toString`, so it yields `chat.simplex.common.model.API$Error@` — then appends the literal text `.err`. The meaningful message (`r.err.string`) is never read. + +This surfaces whenever the core rejects the update. A concrete trigger is a **desynced member role**: the client shows the Save controls because `groupInfo.isOwner` is true, but the core's `assertUserGroupRole gInfo GROwner` (`Commands.hs:3840`) disagrees and returns `CEGroupUserRole`. The display bug then hides which error it was. + +## Fix + +Render the error message instead of the object reference: + +```kotlin +AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}") +``` + +One-line change in `SimpleXAPI.kt`. This is the only occurrence of the `"$r.err"` pattern in the codebase. The underlying core rejection is unchanged — but it is now shown clearly to the user. diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 3f35d652fe..0d93315435 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,28 @@ + + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.5:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 9fab732fca..f03c6071c4 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e250a9ec9d67143db674bc18edb269b64ec1d397" = "14wk4knirk9q7jc7ap0yb17shyk4gv866s61diy2pfj57n8v8xr9"; + "https://github.com/simplex-chat/simplexmq.git"."8e0b8de529bcfee3d9c9a8c28b3a039a4644e9ae" = "087s8jrl6237sh7r9c033s19fh600kp7ii350zi833i1fc349w8z"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e8bdbd70a7..ade2dae61b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.5.0 +version: 7.0.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 59e7a2c941..56ed65fb4b 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -187,8 +187,6 @@ contactsHelpInfo = indent <> highlight "/verify @ " <> " - clear security code verification", indent <> highlight "/info @ " <> " - info about contact connection", indent <> highlight "/switch @ " <> " - switch receiving messages to another SMP relay", - indent <> highlight "/pq @ on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for a contact", - indent <> " " <> " (both have to enable for quantum resistance)", "", green "Contact chat preferences:", indent <> highlight "/set voice @ yes/no/always " <> " - allow/prohibit voice messages with the contact", @@ -324,16 +322,13 @@ settingsInfo = map styleMarkdown [ green "Chat settings:", - indent <> highlight "/pq on/off " <> " - [BETA] toggle quantum resistant / standard e2e encryption for the new contacts", indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp " <> " - show / set configured SMP servers", indent <> highlight "/xftp " <> " - show / set configured XFTP servers", indent <> highlight "/info " <> " - information about contact connection", indent <> highlight "/info # " <> " - information about member connection", indent <> highlight "/(un)mute " <> " - (un)mute contact, the last messages can be printed with /tail command", - indent <> highlight "/(un)mute # " <> " - (un)mute group", - indent <> highlight "/get stats " <> " - get usage statistics", - indent <> highlight "/reset stats " <> " - reset usage statistics" + indent <> highlight "/(un)mute # " <> " - (un)mute group" ] databaseHelpInfo :: [StyledString] diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 45a718d327..c971ab7e96 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4054,9 +4054,9 @@ processChatCommand cxt nm = \case pure (relayMember, conn, groupRelay) let GroupMember {memberRole = userRole, memberId = userMemberId} = membership allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership GroupMember {memberId = relayMemberId} = relayMember - relayInv = GroupRelayInvitation { + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + let relayInv = GroupRelayInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberProfile = membershipProfile, relayMemberId, @@ -4940,17 +4940,7 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () - sendRelayCapIfNeeded cxt gInfo - sendRelayCapIfNeeded cxt gInfo = do - ChatConfig {webPreviewConfig} <- asks config - let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig - sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) - when (currentWebDomain /= sentWebDomain) $ do - owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo - let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners - unless (null capableOwners) $ do - void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) - withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendRelayCapIfNeeded user gInfo checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 92cb02935a..b190a18fd3 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -701,7 +701,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI ci <- xftpAcceptRcvFT db cxt user fileId filePath userApproved rfd <- getRcvFileDescrByRcvFileId db fileId pure (ci, rfd) - receiveViaCompleteFD user fileId rfd userApproved cryptoArgs + receiveViaCompleteFD user fileId rfd fileSize userApproved cryptoArgs pure ci (Nothing, Just _fileConnReq) -> throwChatError $ CEException "accepting file via a separate connection is deprecated" -- group & direct file protocol @@ -743,10 +743,17 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI || (rcvInline_ == Just True && fileSize <= fileChunkSize * offerChunks) ) -receiveViaCompleteFD :: User -> FileTransferId -> RcvFileDescr -> Bool -> Maybe CryptoFileArgs -> CM () -receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} userApprovedRelays cfArgs = +receiveViaCompleteFD :: User -> FileTransferId -> RcvFileDescr -> Integer -> Bool -> Maybe CryptoFileArgs -> CM () +receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} expectedFileSize userApprovedRelays cfArgs = when fileDescrComplete $ do rd <- parseFileDescription fileDescrText + let FD.ValidFileDescription FD.FileDescription {size = FD.FileSize encSize, redirect} = rd + redirectSize = maybe 0 (\FD.RedirectFileInfo {size = FD.FileSize s} -> toInteger s) redirect + -- for a redirect, encSize is the description blob and redirectSize the final file; take the larger + rcvSize = max (toInteger encSize) redirectSize + -- 10 MB margin: encryption and chunk-size rounding make the transfer larger than the advertised size + maxRcvSize = min expectedFileSize (toInteger FD.maxFileSizeHard) + toInteger (FD.mb 10 :: Int64) + when (rcvSize > maxRcvSize) $ throwChatError $ CEFileRcvChunk "declared file size exceeds the file invitation size" if userApprovedRelays then receive' rd True else do @@ -1428,6 +1435,9 @@ updatePublicGroupData user gInfo pure (gInfo', gLink) setGroupLinkDataAsync user gInfo' gLink pure gInfo' + | useRelays' gInfo && isRelay (membership gInfo) = do + cxt <- chatStoreCxt + withStore $ \db -> updatePublicMemberCount db cxt user gInfo | otherwise = pure gInfo updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool) @@ -2299,6 +2309,22 @@ sendInlineBlobChunks user gInfo members sharedMsgId blob = do void $ sendGroupMessage' user gInfo members (BFileChunk sharedMsgId (FileChunk chunkNo chunk)) unless (B.null rest) $ go chSize (chunkNo + 1) rest +-- Relay advertises its current web preview capability to channel owners. +-- Idempotent: sends only when the configured web domain differs from what was last sent, and only to +-- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). +sendRelayCapIfNeeded :: User -> GroupInfo -> CM () +sendRelayCapIfNeeded user gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + cxt <- chatStoreCxt + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages user gInfo scope asGroup members events = do -- TODO [knocking] send current profile to pending member after approval? diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 53b81e9d61..33ac2e78c9 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1941,7 +1941,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processFDMessage fileId aci fileDescr = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - (rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs}) <- withStore $ \db -> do + (rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do rfd <- appendRcvFD db userId fileId fileDescr -- reading second time in the same transaction as appending description -- to prevent race condition with accept @@ -1949,15 +1949,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure (rfd, ft') when fileDescrComplete $ toView $ CEvtRcvFileDescrReady user aci ft' rfd case (fileStatus, xftpRcvFile) of - (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd userApprovedRelays cryptoArgs + (RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd fileSize userApprovedRelays cryptoArgs _ -> pure () processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> CM (Maybe (RcvFileTransfer, CIFile 'MDRcv)) - processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv' -> do + processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv -> do ChatConfig {fileChunkSize} <- asks config - let fInv@FileInvitation {fileName, fileSize} = mkValidFileInvitation fInv' - inline <- receiveInlineMode fInv (Just mc) fileChunkSize - ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize + fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + inline <- receiveInlineMode fInv' (Just mc) fileChunkSize + ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP (filePath, fileStatus, ft') <- case inline of Just IFMSent -> do @@ -1974,6 +1974,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = mkValidFileInvitation :: FileInvitation -> FileInvitation mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = FP.makeValid $ FP.takeFileName fileName} + validateFileInvitation :: FileInvitation -> CM FileInvitation + validateFileInvitation fInv@FileInvitation {fileName, fileSize} + | fileSize > 0 = pure $ mkValidFileInvitation fInv + | otherwise = throwChatError $ CEFileSize fileName + messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> CM () messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -2379,11 +2384,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () - processFileInvitation' ct fInv' msg@RcvMessage {sharedMsgId_} msgMeta = do + processFileInvitation' ct fInv msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config - let fInv@FileInvitation {fileName, fileSize} = mkValidFileInvitation fInv' - inline <- receiveInlineMode fInv Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize + fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + inline <- receiveInlineMode fInv' Nothing fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -2394,10 +2399,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO remove once XFile is discontinued processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> UTCTime -> CM () - processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do + processGroupFileInvitation' gInfo m fInv msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config - inline <- receiveInlineMode fInv Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv inline fileChunkSize + fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv + inline <- receiveInlineMode fInv' Nothing fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -3627,6 +3633,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (useRelays' g'') $ void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' + -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) + when (isRelay (membership g)) $ sendRelayCapIfNeeded user g pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 25201044c9..5e565b8a3f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7285,6 +7285,10 @@ Query: SELECT relay_own_status FROM groups WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT relay_sent_web_domain FROM groups WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT relay_status FROM group_relays Plan: SCAN group_relays diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 2b4fb89137..fc3e4b2a26 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -26,7 +26,7 @@ where import Control.Concurrent.STM (check, flushTQueue) import Control.Exception (SomeException, catch) import Control.Logger.Simple -import Control.Monad (forM_, void, when) +import Control.Monad import Control.Monad.Except (runExceptT) import Data.Either (rights) import Data.Int (Int64) @@ -42,7 +42,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) import Simplex.Chat.Messages ( CChatItem (..), @@ -57,7 +57,7 @@ import Simplex.Chat.Messages ) import Simplex.Chat.Messages.CIContent (ciMsgContent) import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport) -import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups) +import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups, updatePublicMemberCount) import Simplex.Chat.Store.Messages (getGroupWebPreviewItems) import Simplex.Chat.Store.Shared (getGroupInfo) import Simplex.Chat.Types @@ -75,11 +75,11 @@ import Simplex.Chat.Types ) import Simplex.Messaging.Agent.Store.Common (withTransaction) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (safeDecodeUtf8) -import qualified URI.ByteString as U +import Simplex.Messaging.Util (catchOwn, eitherToMaybe, safeDecodeUtf8, tshow) import Simplex.Messaging.Parsers (defaultJSON) import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) import System.FilePath (dropExtension, takeExtension, ()) +import qualified URI.ByteString as U import UnliftIO.STM data WebFileInfo = WebFileInfo @@ -135,7 +135,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva cleanStaleFiles wps regenerateCors wps seedRoutinePending wps - workerLoop wps + forever $ workerLoop wps `catchOwn` \e -> logError ("web preview worker error: " <> tshow e) where cxt = mkStoreCxt (config cc) @@ -146,7 +146,6 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva renderRoutine noRoutine <- atomically $ S.null <$> readTVar routinePending when noRoutine waitRefresh - workerLoop wps where drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case Nothing -> pure () @@ -233,6 +232,12 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI case publicGroup of Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do let fName = publicGroupIdFileName publicGroupId <> ".json" + -- backfill the subscriber count for channels created before it was tracked + subscribers <- case publicMemberCount of + Just _ -> pure publicMemberCount + Nothing -> do + g_ <- withTransaction (chatStore cc) (\db -> runExceptT $ updatePublicMemberCount db cxt user gInfo) + pure $ eitherToMaybe g_ >>= \GroupInfo {groupSummary = GroupSummary {publicMemberCount = pmc}} -> pmc (items, owners) <- withTransaction (chatStore cc) $ \db -> do is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount os <- getGroupOwners db cxt user gInfo @@ -246,7 +251,7 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI shortDescription = toFormattedText =<< sd, welcomeMessage = toFormattedText =<< wd, members = senders, - subscribers = publicMemberCount, + subscribers, messages = msgs, updatedAt = ts } diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 57095fb28f..5d1abcfe21 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -8,6 +8,7 @@ module ChatTests.ChatRelays where import ChatClient import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel, prepareChannel', prepareChannel1Relay, setupRelay) +import ChatTests.Profiles (addTestBadge, issueTestBadge, testBadgeKeys) import ChatTests.Utils import Control.Concurrent (threadDelay) import qualified Data.Aeson as J @@ -16,10 +17,12 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Maybe (fromMaybe) import qualified Data.Text as T import ProtocolTests (testGroupProfile) +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Types (GroupProfile (..)) import Simplex.Chat.Controller (CorsOrigin (..)) import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) +import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) import qualified Data.Set as S @@ -52,6 +55,40 @@ chatRelayTests = do it "share channel card in direct chat" testShareChannelDirect it "share channel card in group" testShareChannelGroup it "share channel card in channel" testShareChannelChannel + describe "channel badges" $ do + it "subscriber and owner see each other's badges forwarded by the relay" testChannelMemberBadges + +-- A channel owner and a subscriber each hold a supporter badge; their member profiles only reach +-- each other forwarded by the relay. Both sides should still see the other's active badge. +testChannelMemberBadges :: HasCallStack => TestParams -> IO () +testChannelMemberBadges ps = do + Right (pk, sk) <- bbsKeyGen + let cfg = testCfg {badgePublicKeys = testBadgeKeys pk} + withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath -> do + addTestBadge alice =<< issueTestBadge sk Nothing + addTestBadge cath =<< issueTestBadge sk Nothing + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + -- a channel message lets the relay-forwarded member profiles settle on both sides + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + -- owner and subscriber are connected only via the relay, so /i shows the badge then "member not connected" for both + alice ##> "/i #team cath" + alice <## "group ID: 1" + alice <##. "member ID: " + alice <## "supporter badge - active" + alice <## "no expiry" + alice <## "member not connected" + cath ##> "/i #team alice" + cath <## "group ID: 1" + cath <##. "member ID: " + cath <## "supporter badge - active" + cath <## "no expiry" + cath <## "member not connected" testGetSetChatRelays :: HasCallStack => TestParams -> IO () testGetSetChatRelays ps = diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 34ee893dd3..cec2aa0a01 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -148,7 +148,7 @@ - {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) %} + {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) and ('news' not in page.url) %}