Merge branch 'master' into f/public-groups

This commit is contained in:
spaced4ndy
2026-06-18 14:19:44 +04:00
40 changed files with 732 additions and 153 deletions
@@ -23,6 +23,7 @@ struct ComposeFileView: View {
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.leading, 4)
Text(fileName)
.lineLimit(1)
Spacer()
if cancelEnabled {
Button { cancelFile() } label: {
@@ -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()
@@ -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.")
@@ -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 {
+20 -20
View File
@@ -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;
@@ -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 -> {
@@ -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
)
@@ -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(
@@ -330,7 +330,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
val imagesPreview = ArrayList<String>()
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<ComposeState>.processPickedMedia(uris: List<URI>, 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<ComposeState>.processPickedMedia(uris: List<URI>, 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()) {
@@ -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()
}
}
@@ -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 {
@@ -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) {
@@ -413,12 +413,12 @@ private fun MutableState<MigrationFromState>.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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: e250a9ec9d67143db674bc18edb269b64ec1d397
tag: 8e0b8de529bcfee3d9c9a8c28b3a039a4644e9ae
source-repository-package
type: git
@@ -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",
+2 -2
View File
@@ -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"
@@ -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') {
@@ -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)
@@ -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<String>, val content: List<UploadContent>)
```
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.
@@ -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.
@@ -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.
+42
View File
@@ -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 @<name> 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 #<group>` 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 <userId>` 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 @<name>` 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 #<group>` the last `settingsInfo` element, so its trailing comma is removed to avoid a dangling-comma parse error before `]`.
@@ -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.
@@ -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@<hash>` — 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.
@@ -38,6 +38,28 @@
</description>
<releases>
<release version="6.5.5" date="2026-06-17">
<url type="details">https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html</url>
<description>
<p>New in v6.5.5:</p>
<p>Public channels - speak freely!</p>
<ul>
<li>Reliability: many relays per channel.</li>
<li>Ownership: you can run your own relays.</li>
<li>Security: owners hold channel keys.</li>
<li>Privacy: for owners and subscribers.</li>
</ul>
<p>Easier to invite your friends: we made connecting simpler for new users.</p>
<p>Safe web links:</p>
<ul>
<li>opt-in to send link previews.</li>
<li>use SOCKS proxy for previews (if enabled).</li>
<li>prevent hyperlink phishing.</li>
<li>remove link tracking.</li>
</ul>
<p>Non-profit governance: to make SimpleX Network last.</p>
</description>
</release>
<release version="6.5.4" date="2026-06-02">
<url type="details">https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html</url>
<description>
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+1 -6
View File
@@ -187,8 +187,6 @@ contactsHelpInfo =
indent <> highlight "/verify @<name> " <> " - clear security code verification",
indent <> highlight "/info @<name> " <> " - info about contact connection",
indent <> highlight "/switch @<name> " <> " - switch receiving messages to another SMP relay",
indent <> highlight "/pq @<name> 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 @<name> 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 <contact> " <> " - information about contact connection",
indent <> highlight "/info #<group> <member> " <> " - information about member connection",
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",
indent <> highlight "/(un)mute #<group> " <> " - (un)mute group",
indent <> highlight "/get stats " <> " - get usage statistics",
indent <> highlight "/reset stats " <> " - reset usage statistics"
indent <> highlight "/(un)mute #<group> " <> " - (un)mute group"
]
databaseHelpInfo :: [StyledString]
+3 -13
View File
@@ -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)
+29 -3
View File
@@ -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?
+21 -13
View File
@@ -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)
@@ -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
+13 -8
View File
@@ -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
}
+37
View File
@@ -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 =
+1 -1
View File
@@ -148,7 +148,7 @@
</svg>
</button>
{% 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) %}
<div class="nav-link flag-container">
<a href="javascript:void(0);">
{% for language in languages.languages %}
+2 -1
View File
@@ -41,7 +41,8 @@ active_blog: true
<section class="py-10 px-5 mt-[66px]" id="blog-list">
<div class="container">
<h1 class="text-[38px] text-center font-bold mb-9">Latest news</h1>
<h1 class="text-[38px] text-center font-bold mb-2">Latest news</h1>
<p class="text-right mb-9"><a class="text-primary-light dark:text-[#70F0F9] text-sm font-medium tracking-[0.03em]" href="/news">Open SimpleX Network News channel</a></p>
{% for blog in collections.blogs %}
{% if not(blog.data.draft) %}
+70 -12
View File
@@ -1,7 +1,7 @@
#include "simplex-lib.jsc"
(function() {
#include "simplex-lib.jsc"
#include "qrcode.js"
const STYLE = `
@@ -94,6 +94,15 @@ const STYLE = `
text-overflow: ellipsis;
}
.simplex-preview-container .simplex-preview-header-name {
background: none;
-webkit-background-clip: border-box;
background-clip: border-box;
color: var(--sp-text);
-webkit-text-fill-color: var(--sp-text);
text-fill-color: var(--sp-text);
}
.simplex-preview-header-description {
font-size: 13px;
color: var(--sp-text-secondary);
@@ -281,6 +290,7 @@ const STYLE = `
.simplex-preview-quote-text {
font-size: 15px;
overflow: hidden;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -492,16 +502,18 @@ const STYLE = `
min-width: 0;
max-width: 640px;
overflow-y: auto;
overscroll-behavior: contain;
position: relative;
}
.simplex-preview-info {
overflow-y: auto;
overscroll-behavior: contain;
background: var(--sp-bg);
}
.simplex-preview-scroll-lock {
overflow: hidden;
}
.simplex-preview-info-close {
display: none;
}
@@ -696,12 +708,13 @@ const STYLE = `
.simplex-preview-info {
display: none;
position: fixed;
top: 0;
top: var(--sp-top-offset, 0px);
left: 0;
right: 0;
bottom: 0;
z-index: 100;
padding: 16px;
overscroll-behavior: contain;
}
.simplex-preview-info.open {
display: block;
@@ -786,6 +799,13 @@ function initChannelPreview(container) {
if (container.dataset.darkBackground) {
container.style.setProperty('--sp-dark-bg', container.dataset.darkBackground);
}
const topOffset = parseInt(container.dataset.topOffset || '0', 10);
if (topOffset > 0) {
container.style.setProperty('--sp-top-offset', topOffset + 'px');
container.style.marginTop = topOffset + 'px';
container.style.height = 'calc(100vh - ' + topOffset + 'px)';
container.style.height = 'calc(100dvh - ' + topOffset + 'px)';
}
container.innerHTML = '<p class="simplex-preview-empty">Loading channel...</p>';
fetchPreview(relayScheme, relayDomains, channelLink, channelId).then(data => {
@@ -885,12 +905,16 @@ function render(container, data, channelLink, showAppBadges) {
if (window.innerWidth < 1000) {
info.classList.add('open');
main.style.overflow = 'hidden';
document.documentElement.classList.add('simplex-preview-scroll-lock');
document.body.classList.add('simplex-preview-scroll-lock');
}
});
closeBtn.addEventListener('click', () => {
info.classList.remove('open');
main.style.overflow = '';
document.documentElement.classList.remove('simplex-preview-scroll-lock');
document.body.classList.remove('simplex-preview-scroll-lock');
});
setupSecretToggles(container);
@@ -1028,6 +1052,32 @@ function renderAppBadges(container) {
container.appendChild(badges);
}
// the QR library only parses hex colors; map 'transparent' (used in dark mode) to a transparent hex
function qrColor(value, fallback) {
value = (value || '').trim();
if (!value) return fallback;
return value === 'transparent' ? '#0000' : value;
}
// navigator.clipboard is undefined outside secure contexts; fall back to execCommand
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-9999px';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
ok ? resolve() : reject(new Error('copy failed'));
});
}
function renderDesktopConversion(container, channelLink, showAppBadges) {
if (showAppBadges) {
renderAppBadges(container);
@@ -1070,8 +1120,8 @@ function renderDesktopConversion(container, channelLink, showAppBadges) {
QRCode.toCanvas(canvas, channelLink, {
errorCorrectionLevel: 'M',
color: {
dark: cs.getPropertyValue('--sp-qr-fg').trim() || '#062D56',
light: cs.getPropertyValue('--sp-qr-bg').trim() || '#ffffff'
dark: qrColor(cs.getPropertyValue('--sp-qr-fg'), '#062D56'),
light: qrColor(cs.getPropertyValue('--sp-qr-bg'), '#ffffff')
},
width: 400,
margin: 1
@@ -1079,12 +1129,14 @@ function renderDesktopConversion(container, channelLink, showAppBadges) {
canvas.style.width = '200px';
canvas.style.height = '200px';
}).catch(function() {
canvas._rendered = false;
qrPopup.style.display = 'none';
qrToggle.style.display = 'none';
qrToggle.style.display = '';
});
} catch(err) {
canvas._rendered = false;
qrPopup.style.display = 'none';
qrToggle.style.display = 'none';
qrToggle.style.display = '';
}
}
} else {
@@ -1100,10 +1152,10 @@ function renderDesktopConversion(container, channelLink, showAppBadges) {
const copyLink = document.createElement('a');
copyLink.textContent = 'copy link';
copyLink.addEventListener('click', function() {
navigator.clipboard.writeText(channelLink).then(function() {
copyToClipboard(channelLink).then(function() {
copyLink.textContent = 'Copied!';
setTimeout(function() { copyLink.textContent = 'copy link'; }, 2000);
});
}).catch(function() {});
});
copyAction.appendChild(document.createTextNode('Or '));
copyAction.appendChild(copyLink);
@@ -1318,7 +1370,11 @@ function renderQuote(quote, membersMap, channel) {
function classifyImage(img) {
const w = img.naturalWidth;
const h = img.naturalHeight;
img.classList.add(w * 0.97 <= h ? 'portrait' : 'landscape');
const portrait = w * 0.97 <= h;
img.classList.add(portrait ? 'portrait' : 'landscape');
// constrain the bubble to the image width so long text wraps instead of widening the bubble
const inner = img.closest('.simplex-preview-bubble-inner');
if (inner) inner.style.maxWidth = portrait ? '300px' : '400px';
}
function renderImageContent(inner, mc, msg, mediaOnly) {
@@ -1372,6 +1428,8 @@ function renderVideoContent(inner, mc, msg, mediaOnly) {
function renderLinkContent(bubble, mc, msg) {
if (mc.preview) {
// clamp the bubble to the link card width so long text wraps instead of widening the bubble
bubble.style.maxWidth = '400px';
const card = document.createElement('div');
card.className = 'simplex-preview-link-card';
if (isDataImage(mc.preview.image)) {
+1 -2
View File
@@ -1,5 +1,3 @@
#include "simplex-lib.jsc"
const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/';
// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/';
@@ -7,6 +5,7 @@ const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/';
const simplexUsersGroup = 'SimpleX users group';
(function() {
#include "simplex-lib.jsc"
if (!document.location.pathname.startsWith('/directory')) return;
let allEntries = [];
+16
View File
@@ -0,0 +1,16 @@
---
layout: layouts/main.html
title: "SimpleX Network News"
description: "The news about SimpleX Network technology, governance and anything related to its development."
templateEngineOverride: njk
---
<div data-simplex-channel-preview
data-channel-link="https://smp18.simplex.im/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA"
data-channel-id="8Ge2ASVRkbDhdJc05ZgSSnZmSjw3UMUaQW_QttI0j_w="
data-relay-domains="relay1.simplex.chat,relay2.simplex.chat"
data-app-download-buttons="off"
data-color-scheme="site"
data-top-offset="54"
></div>
<script src="/js/channel-preview.js"></script>