diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bee25da90b..387ae7e350 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -637,7 +637,8 @@ jobs: toolchain:p cmake:p - # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + # rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing + # (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) - name: Build CLI id: windows_cli_build shell: msys2 {0} @@ -652,10 +653,10 @@ jobs: echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local - rm -rf dist-newstyle/src/direct-sq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* sed -i "s/, unix /--, unix /" simplex-chat.cabal cabal build -j --enable-tests - rm -rf dist-newstyle/src/direct-sq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT @@ -679,7 +680,7 @@ jobs: scripts/desktop/build-lib-windows.sh cd apps/multiplatform ./gradlew -Psimplex.assets.dir=../../assets packageMsi - rm -rf dist-newstyle/src/direct-sq* + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 9aaff57cc5..11c3c4c3f4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -191,9 +191,14 @@ private func handleTextTaps( } } } - if let index, let (uri, browser) = attributedStringLink(s, for: index) { + if let index, let (uri, browser, simplex) = attributedStringLink(s, for: index) { if browser { openBrowserAlert(uri: uri) + } else if simplex, let url = URL(string: uri) { + // SimpleX links target this same app (simplex: scheme / simplex.chat universal link), + // so UIApplication.shared.open is dropped by iOS while the app is in the foreground. + // Route to the in-app connect flow instead (same sink onOpenURL feeds). + ChatModel.shared.appOpenUrl = url } else if let url = URL(string: uri) { UIApplication.shared.open(url) } else { @@ -203,9 +208,10 @@ private func handleTextTaps( }) } - func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? { + func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool, Bool)? { var linkURL: String? var browser: Bool = false + var simplex: Bool = false s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in if index >= range.location && index < range.location + range.length { if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo { @@ -213,6 +219,7 @@ private func handleTextTaps( } else if let url = attrs[linkAttrKey] as? String { linkURL = url browser = attrs[webLinkAttrKey] != nil + simplex = attrs[simplexLinkAttrKey] != nil } else if let showSecrets, let i = attrs[secretAttrKey] as? Int { if showSecrets.wrappedValue.contains(i) { showSecrets.wrappedValue.remove(i) @@ -225,7 +232,7 @@ private func handleTextTaps( stop.pointee = true } } - return if let linkURL { (linkURL, browser) } else { nil } + return if let linkURL { (linkURL, browser, simplex) } else { nil } } } @@ -250,6 +257,8 @@ private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link") private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink") +private let simplexLinkAttrKey = NSAttributedString.Key("chat.simplex.app.simplexLink") + private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command") @@ -392,6 +401,7 @@ func messageText( attrs = linkAttrs() if !preview { attrs[linkAttrKey] = simplexUri + attrs[simplexLinkAttrKey] = true handleTaps = true } if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) { 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/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 3047572236..5844fd3ff9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -26,7 +26,9 @@ struct ChatHelp: View { Button("connect to SimpleX Chat developers.") { dismissSettingsSheet() DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) + // simplexTeamURL targets this same app; route to the in-app connect flow + // (UIApplication.shared.open is dropped for self-owned URLs in the foreground) + ChatModel.shared.appOpenUrl = simplexTeamURL } } .padding(.top, 2) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c1bc699261..483ca6aea8 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -390,7 +390,9 @@ struct SettingsView: View { Button("Send questions and ideas") { dismiss() DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) + // simplexTeamURL targets this same app; route to the in-app connect flow + // (UIApplication.shared.open is dropped for self-owned URLs in the foreground) + ChatModel.shared.appOpenUrl = simplexTeamURL } } } 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/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index f6a6023d47..c4c34a1db9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -321,7 +321,11 @@ private suspend fun downloadAsset(asset: GitHubAsset) { stream.copyTo(output) } val newFile = File(file.parentFile, asset.name) - file.renameTo(newFile) + // Moving instead of renameTo: a bare rename can silently fail (returns false, ignored), + // and the enclosing createTmpFileAndDelete then deletes the only copy in its finally block, + // leaving the user with an empty download dir. Files.move performs the same in-place rename + // when possible, falls back to copy when it can't, and throws (handled below) on real failure. + Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING) AlertManager.shared.showAlertDialogButtonsColumn( generalGetString(MR.strings.app_check_for_updates_download_completed_title), diff --git a/cabal.project b/cabal.project index d6151cc620..06622a65a2 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: 376d6a261a1074717aed65ad97bb6f2a9532011b + tag: 92598c2ddb06cfc2c19797a2c900cffcb8af4d5c source-repository-package type: git diff --git a/plans/2026-06-19-fix-updater-open-file-location.md b/plans/2026-06-19-fix-updater-open-file-location.md new file mode 100644 index 0000000000..7e4d7e0af1 --- /dev/null +++ b/plans/2026-06-19-fix-updater-open-file-location.md @@ -0,0 +1,48 @@ +# Fix: in-app updater deletes the downloaded file before the user can open/install it + +## Symptom + +Desktop in-app updater (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt`): after a successful download the "Download completed" dialog appears, but clicking **"Open file location"** opens an **empty** `/tmp/simplex` — the downloaded artifact is gone. Reported against a Linux AppImage build; verified from a terminal that the file was genuinely absent on disk (not a file-manager display glitch). + +## Root cause + +`downloadAsset` writes the download to a temporary UUID-named file created by `createTmpFileAndDelete`, whose contract is to delete that temp file in a `finally` block. To keep the bytes, the code renames the temp file to the asset name so the survivor sits at a *different* path than the one the `finally` deletes: + +```kotlin +createTmpFileAndDelete { file -> // file = /tmp/simplex/ + file.outputStream().use { output -> stream.copyTo(output) } + val newFile = File(file.parentFile, asset.name) + file.renameTo(newFile) // return value IGNORED + ... show "Download completed" dialog ... +} // finally { tmpFile.delete() } +``` + +`File.renameTo` returns a boolean and **its result was ignored**. When the rename succeeds (the common case) the survivor is `newFile` and the `finally` deletes the now-absent UUID path (a no-op) — everything works. But if the rename returns `false`, the bytes stay at the UUID path, `newFile` is never created, and the `finally { tmpFile.delete() }` deletes the only copy. The dialog is still shown (the rename result was never checked), so the user sees "Download completed" over an empty directory. + +The download path is **shared by every platform and asset type** (`.AppImage`, `.deb`, Windows `.msi`, macOS `.dmg`), so this affected all of them — most acutely `.deb`, where the in-app "Install" button is hidden and "Open file location" is the only way forward. + +## Fix + +Replace the unchecked `renameTo` with `Files.move(..., REPLACE_EXISTING)`: + +```kotlin +val newFile = File(file.parentFile, asset.name) +Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING) +``` + +Behaviour: + +- **Same in-place rename in the normal case.** Verified that a same-directory `Files.move` preserves the inode — it executes the same `rename(2)` syscall as `renameTo`, with no copy. No behaviour or performance change on the happy path. +- **Recovers when an in-place rename is not possible** (e.g. cross-filesystem): falls back to copy-then-delete, so `newFile` still ends up present. +- **Surfaces genuine failures** by throwing, which the existing outer `catch (e: Exception)` in `downloadAsset` already handles (logs the error) — instead of silently deleting the download and showing a misleading "Download completed" dialog. + +One line changed (plus a clarifying comment). `Files` / `StandardCopyOption` are already imported. + +## Compatibility impact + +Before this change the updater's download step worked only on *some* Linux systems — it failed on those where `File.renameTo` returns `false`. In particular it did **not** work on **Whonix**, where the "Download completed" dialog appeared over an empty folder and the update could not proceed. `Files.move` succeeds on those systems too (in-place rename when possible, copy+delete otherwise, throwing only on genuine failure), so this fix expands the set of platforms on which the in-app updater works — Whonix included — without changing behaviour where `renameTo` already succeeded. + +## Scope / out of scope + +- This change is limited to the shared download step; it fixes the reported symptom on every OS at once. +- The per-OS *install* paths are untouched. A separate, related fragility remains in the macOS install branch (`File("/Applications/SimpleX.app").renameTo(...)` return value ignored at the app-replace/restore steps); it is a different symptom (botched/missing install, not a lost download) and is left for a follow-up, consistent with the out-of-scope list in `plans/2026-05-16-desktop-updater-fixes.md`. diff --git a/plans/2026-06-19-ios-open-simplex-links-in-messages.md b/plans/2026-06-19-ios-open-simplex-links-in-messages.md new file mode 100644 index 0000000000..f7c89b303a --- /dev/null +++ b/plans/2026-06-19-ios-open-simplex-links-in-messages.md @@ -0,0 +1,65 @@ +# iOS: open SimpleX links in chat messages via in-app connect flow + +## Problem + +On iOS, tapping a **SimpleX connection/invitation link inside message text** does nothing — it never reaches the connection flow. Reproduced on iPhone 17 (v6.5.2 and v6.5.5). On the same screens, tapping a web link (opens browser), a `mailto:`/`tel:` link, and the connection-link **card** all work. Notably it was **device-specific**: dead on an iPhone 17 but working on an iPhone 12 running the **same iOS version**, with only **one** SimpleX app installed. + +## Root cause + +Inline links are dispatched in `MsgContentView.handleTextTaps` (`apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift`): + +- web links (`webLinkAttrKey`) → `openBrowserAlert` → `UIApplication.shared.open` (Safari) +- everything else → `UIApplication.shared.open(url)` + +SimpleX links fell into the second branch. Two facts make this the bug: + +1. **The URI is always the `simplex:` custom scheme.** The core markdown parser normalizes every connection link to the `simplex:` scheme via `simplexConnReqUri` / `simplexShortLink` (`src/Simplex/Chat/Markdown.hs:344,353`), regardless of whether the message contained `https://simplex.chat/…` or `simplex:/…` (see `tests/MarkdownTests.hs`). So the tap always calls `UIApplication.shared.open("simplex:/contact#…")`. + +2. **`simplex:` is registered to this app, and the app is in the foreground.** `UIApplication.shared.open` is an OS app-launch API: it asks iOS (LaunchServices) to resolve the scheme to its registered app and activate it. Here the registered app is SimpleX itself, already foregrounded. **Re-entering the same foreground app through `open()` is not a supported operation** — `open()` exists to hand a URL to a *different* app or the system. When the resolved target is the calling foreground app, the outcome is undefined: on some devices iOS still delivers the URL to `onOpenURL`, on others it is a silent no-op (`open` returns `false`, no error, no UI). + +That undefined outcome is decided by device-local OS state (scheme resolution / launch services), which is why identical code + identical OS + identical single app behaved differently on the iPhone 12 (delivered → connected) and the iPhone 17 (no-op → dead). It is **not** an OS-version rule and **not** a multiple-handler conflict — both were ruled out (same OS; single install). + +This also explains the full symptom matrix — only the path that re-enters the same app via `open()` is affected: + +| Tapped | Dispatch | Target | Result | +|---|---|---|---| +| Web link | `openBrowserAlert` → `open()` | Safari (other app) | works | +| `mailto:` / `tel:` | `open()` | Mail / Phone (other apps) | works | +| Invite card | `planAndConnect` in-process | this app, no `open()` | works | +| Inline SimpleX link | `open("simplex:…")` | this app (self), foreground | undefined → dead | + +The underlying cause is using the **wrong mechanism**: an OS hand-off API to perform an **in-app** action. Every other connect path handles the connection in-process and never leaves the app: + +- the card: `planAndConnect` directly (`FramedItemView.swift`) +- the share extension: `ShareSheet.openExternalLink` sets `ChatModel.appOpenUrl` +- multiplatform: `openVerifiedSimplexUri` → `connectIfOpenedViaUri` → `planAndConnect` + +Inline links were the lone exception delegating to the OS, making them hostage to undefined self-open behavior. + +## Fix + +Restore the three-way dispatch the multiplatform clients use (`WEB_URL` / `OTHER_URL` / `SIMPLEX_URL`): + +- web → `openBrowserAlert` (unchanged) +- `mailto:` / `tel:` → `UIApplication.shared.open` (unchanged — these target other apps) +- **SimpleX → `ChatModel.appOpenUrl`** — the same sink `onOpenURL` feeds, leading to `connectViaUrl` → `planAndConnect`, entirely **in-process** with no OS round-trip + +SimpleX links are identified by a dedicated attribute key (`simplexLinkAttrKey`) set on the `.simplexLink` format, mirroring the multiplatform `SIMPLEX_URL` annotation tag, rather than sniffing the URL string — so all link types (contact, invitation, group, channel, relay) are covered. + +This is correct regardless of the exact device-local trigger, because it removes the dependency on iOS re-delivering a self-owned URL. The invite card already proves the in-process path works on the affected device. + +Also fixes the same issue for the **"Send questions and ideas"** (Settings) and **"connect to SimpleX Chat developers"** (chat help) buttons, which opened `simplexTeamURL` (a `simplex:` link) the same broken way. + +## Scope + +- `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift` — three-way tap dispatch + `simplexLinkAttrKey` +- `apps/ios/Shared/Views/UserSettings/SettingsView.swift`, `apps/ios/Shared/Views/ChatList/ChatHelp.swift` — route `simplexTeamURL` in-process + +No behavior change for web / `mailto:` / `tel:` links. + +## Verification + +- Tap an inline SimpleX invitation/contact link in a received message → the connection sheet opens (on iPhone 17, where it was previously dead). +- The two developer-contact buttons open the connect flow. +- Web links still open the browser; `mailto:`/`tel:` still open Mail/Phone. +- Optional, to confirm the device-local nature: open a `simplex:/contact#…` link from another app (e.g. Notes) on the affected device — if that is also dead there but works on a second device, it confirms the difference is device-local scheme resolution rather than app code. diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index af408d4054..fdf7154491 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -38,8 +38,9 @@ scripts/desktop/prepare-openssl-windows.sh openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm -rf $BUILD_DIR 2>/dev/null || true -# Existence of this directory produces build error: cabal's bug -rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true +# Existence of these directories produces build error: cabal's bug +# (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) +rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* 2>/dev/null || true rm cabal.project.local 2>/dev/null || true echo "ignore-project: False" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 150a2a4e6f..8519d08c3c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."376d6a261a1074717aed65ad97bb6f2a9532011b" = "1j83kzjcgjr7ngbamby96r90yal80c6kv79l9shy05mppmp73f4y"; + "https://github.com/simplex-chat/simplexmq.git"."92598c2ddb06cfc2c19797a2c900cffcb8af4d5c" = "0x98w50vcb2qwdxgkgfvygn1vzclb12zg1k6kcs9rrw54rhmvfmp"; "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 20b71a052d..74a96c1371 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: 6.5.6.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 43f480b5c4..e11a8bd523 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3675,7 +3675,7 @@ processChatCommand cxt nm = \case profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of Just gInfo_' -> - let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' + let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True chatEvent <- case gInfo_ of @@ -3988,10 +3988,10 @@ processChatCommand cxt nm = \case conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode pure (relayMember, conn, groupRelay) let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + allowSimplexLinks = groupUserAllowSimplexLinks gInfo 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, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 68e870a7c5..79fff87e5b 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -367,7 +367,7 @@ prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole prohibitedSimplexLinks :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Bool prohibitedSimplexLinks gInfo m mc ft = not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) - && (isChatLink mc || maybe False (any ftIsSimplexLink) ft) + && (isChatLink mc || maybe False (any ftIsSimplexLink) ft || hasObfuscatedSimplexLink (msgContentText mc)) where isChatLink = \case MCChat {} -> True @@ -1177,7 +1177,7 @@ introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn sendGroupMemberMessages user gInfo conn introEvts' userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile -userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks +userProfileInGroup user = userProfileInGroup' user . groupUserAllowSimplexLinks {-# INLINE userProfileInGroup #-} userProfileInGroup' :: User -> Bool -> Maybe Profile -> Profile @@ -1195,7 +1195,7 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a memberKey = MemberKey <$> memberPubKey } where - allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g && groupFeatureMemberAllowed SGFDirectMessages m g redactedMemberProfile :: Bool -> Profile -> Profile redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} = @@ -1203,6 +1203,7 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes where removeSimplexLink s | allowSimplexLinks = Just s + | hasObfuscatedSimplexLink s = Nothing | otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s sendHistory :: User -> GroupInfo -> GroupMember -> CM () @@ -2129,7 +2130,7 @@ sendGroupMessages user gInfo scope asGroup members events = do _ -> False sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + allowSimplexLinks = groupUserAllowSimplexLinks gInfo -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f8cb2b861c..a058857076 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -731,8 +731,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ct <- getContactViaMember db cxt user m liftIO $ setNewContactMemberConnRequest db user m cReq liftIO $ (ct,) <$> getGroupLinkId db user gInfo - sendGrpInvitation ct m groupLinkId - toView $ CEvtSentGroupInvitation user gInfo ct m + if memberRole' membership >= GRAdmin + then do + sendGrpInvitation ct m groupLinkId + toView $ CEvtSentGroupInvitation user gInfo ct m + else messageError "processGroupMessage: group link host no longer has admin role" where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -813,7 +816,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XGrpMemInfo memId _memProfile | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + allowSimplexLinks = groupUserAllowSimplexLinks gInfo membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability @@ -1535,9 +1538,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do -- TODO [short links] deduplicate request by xContactId? gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId - if useRelays' gInfo - then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" - else do + if + | useRelays' gInfo -> + messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" + | memberRole' (membership gInfo) < GRAdmin -> + messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored join request because host is no longer admin" + | otherwise -> do acceptMember_ <- asks $ acceptMember . chatHooks . config maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case Right (acceptance, useRole) @@ -1566,20 +1572,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createRelayRequestGroup db cxt user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited lift $ void $ getRelayRequestWorker True xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () - xGrpRelayTest invId chatVRange challenge = do - privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) - case privKey_ of - Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") - Just privKey -> do - let sig = C.signatureBytes $ C.sign' privKey challenge - msg = XGrpRelayTest challenge (Just sig) - subMode <- chatReadVar subscriptionMode - chatVR <- chatVersionRange - let chatV = chatVR `peerConnChatVersion` chatVRange - (cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV - withStore $ \db -> do - Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode - liftIO $ setCommandConnId db user cmdId testCId + xGrpRelayTest invId chatVRange challenge + | isTrue userChatRelay && isNothing ucGroupId_ = + withAgent (`getConnLinkPrivKey` aConnId conn) >>= \case + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.signatureBytes $ C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` chatVRange + (cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV + withStore $ \db -> do + Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode + liftIO $ setCommandConnId db user cmdId testCId + | otherwise = messageError "relay test sent to non-relay link" + where + User {userChatRelay} = user -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM () @@ -2701,7 +2710,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure m where contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p')) - allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo && groupFeatureMemberAllowed SGFDirectMessages m gInfo updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' @@ -3090,7 +3099,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure toMember subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + let allowSimplexLinks = groupUserAllowSimplexLinks gInfo membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability @@ -3113,7 +3122,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where GroupMember {memberId = membershipMemId} = membership changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent - | senderRole < GRAdmin || senderRole < fromRole = + | senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 9507375527..e8cd381941 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -18,6 +18,7 @@ import Control.Monad import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as AB import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A import Data.ByteString.Char8 (ByteString) @@ -191,6 +192,16 @@ isLink = \case hasLinks :: MarkdownList -> Bool hasLinks = any $ \(FormattedText f _) -> maybe False isLink f +hasObfuscatedSimplexLink :: Text -> Bool +hasObfuscatedSimplexLink t = + fromRight False $ AB.parseOnly findLinkP $ encodeUtf8 $ T.filter (not . isSpace) t + where + findLinkP = do + AB.skipWhile (\c -> c /= 's' && c /= 'h') -- links start only with "simplex:" or "https://" + (True <$ (strP :: AB.Parser AConnectionLink)) + <|> (AB.anyChar *> findLinkP) + <|> pure False + markdownP :: Parser Markdown markdownP = mconcat <$> A.many' fragmentP where diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f8ccaa74e7..86202fe598 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -932,7 +932,7 @@ parseChatMessages msg = case B.head msg of Right (compressed :: L.NonEmpty Compressed) -> case traverse decompressedSize compressed of Nothing -> [Left "compressed size not specified"] Just sizes - | sum sizes > maxDecompressedMsgLength -> [Left "decompressed size exceeds limit"] + | any (maxDecompressedMsgLength <) sizes || maxDecompressedMsgLength < sum sizes -> [Left "decompressed size exceeds limit"] | otherwise -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1) compressed parseUncompressed' "" = [Left "empty string"] parseUncompressed' s = parseUncompressed (B.head s) s diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 204b5325ed..cd18e62148 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -355,7 +355,7 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur map (toContactMember currentTs cxt user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_member_id IN ?") + (groupMemberQuery <> " WHERE m.group_member_id IN ? ORDER BY m.group_member_id ASC") (Only (In gmIds)) #else rights <$> mapM (runExceptT . getGroupMemberById db cxt user) gmIds 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 803e012773..e7188f3cc2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1134,6 +1134,19 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_role = 'owner' + WHERE member_category = 'user' + AND group_id IN ( + SELECT group_id FROM groups WHERE local_display_name = 'team' + ) + +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +LIST SUBQUERY 1 +SCAN groups USING COVERING INDEX sqlite_autoindex_groups_1 + Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7155d407e8..10f492c328 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -638,6 +638,12 @@ groupFeatureUserAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupInfo - groupFeatureUserAllowed feature GroupInfo {membership = GroupMember {memberRole}, fullGroupPreferences} = groupFeatureMemberAllowed' feature memberRole fullGroupPreferences +-- A connection link in a profile description enables a direct connection, so a description +-- keeps its links only when both SimpleX links and direct messages are allowed. +groupUserAllowSimplexLinks :: GroupInfo -> Bool +groupUserAllowSimplexLinks g = + groupFeatureUserAllowed SGFSimplexLinks g && groupFeatureUserAllowed SGFDirectMessages g + mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct) diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 4b09347dcf..e5c2598f41 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -6,6 +6,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 @@ -14,8 +15,10 @@ 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.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) import Test.Hspec hiding (it) @@ -32,6 +35,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/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 82906110c6..c8cd1c5f30 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -81,6 +81,7 @@ chatGroupTests = do it "group live message" testGroupLiveMessage it "update group profile" testUpdateGroupProfile it "update member role" testUpdateMemberRole + it "check owner role change" testOwnerRoleChange it "group description is shown as the first message to new members" testGroupDescription it "moderate message of another group member" testGroupModerate it "moderate own message (should process as deletion)" testGroupModerateOwn @@ -108,6 +109,7 @@ chatGroupTests = do it "invitee incognito" testGroupLinkInviteeIncognito it "incognito - join/invite" testGroupLinkIncognitoJoinInvite it "group link member role" testGroupLinkMemberRole + it "demotion does not remove group link" testGroupLinkDemotedAdmin it "host profile received" testGroupLinkHostProfileReceived it "existing contact merged" testGroupLinkExistingContactMerged describe "group links - member screening" $ do @@ -1608,6 +1610,37 @@ testUpdateMemberRole = alice ##> "/mr team alice admin" alice <## "bad chat command: can't change role for self" +testOwnerRoleChange :: HasCallStack => TestParams -> IO () +testOwnerRoleChange = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + void $ withCCTransaction cath $ \db -> + DB.execute_ + db + [sql| + UPDATE group_members + SET member_role = 'owner' + WHERE member_category = 'user' + AND group_id IN ( + SELECT group_id FROM groups WHERE local_display_name = 'team' + ) + |] + + cath ##> "/mr #team bob owner" + cath <## "#team: you changed the role of bob to owner" + concurrentlyN_ + [ alice <## "error: x.grp.mem.role with insufficient member permissions", + bob <## "error: x.grp.mem.role with insufficient member permissions" + ] + + bob ##> "/ms team" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, you, connected", + "cath (Catherine): admin, connected" + ] + testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob @@ -2929,6 +2962,25 @@ testGroupLinkMemberRole = bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" +testGroupLinkDemotedAdmin :: HasCallStack => TestParams -> IO () +testGroupLinkDemotedAdmin = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob _cath -> do + createGroup2' "team" alice (bob, GRAdmin) True + + bob ##> "/create link #team member" + _gLink <- getGroupLink bob "team" GRMember True + + alice ##> "/mr #team bob member" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob to member", + bob <## "#team: alice changed your role from admin to member" + ] + + -- demotion does not remove bob's group link (it is preserved, usable again on re-promotion) + bob ##> "/show link #team" + void $ getGroupLink bob "team" GRMember False + testGroupLinkHostIncognito :: HasCallStack => TestParams -> IO () testGroupLinkHostIncognito = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 0e2052b259..0efdd6baa2 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2903,6 +2903,12 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil bob <## "bad chat command: feature not allowed SimpleX links" bob ##> ("/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"" <> inv <> "\\ntest\"}}]") bob <## "bad chat command: feature not allowed SimpleX links" + -- a link split with a space or a newline is still blocked + let (lnk1, lnk2) = splitAt 12 inv + bob ##> ("#team \"" <> lnk1 <> " " <> lnk2 <> "\"") + bob <## "bad chat command: feature not allowed SimpleX links" + bob ##> ("#team \"" <> lnk1 <> "\\n" <> lnk2 <> "\"") + bob <## "bad chat command: feature not allowed SimpleX links" (alice inv <> "\\ntest\"") diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index efa010ceb1..2a5328ff26 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -25,6 +25,7 @@ markdownTests = do textColor textWithUri textWithHyperlink + obfuscatedSimplexLinks textWithEmail textWithPhone textWithMentions @@ -284,6 +285,24 @@ textWithHyperlink = describe "text with HyperLink without link text" do "[click here](example.com)" <==> "[click here](example.com)" "[click here](https://example.com )" <==> "[click here](https://example.com )" +obfuscatedSimplexLinks :: Spec +obfuscatedSimplexLinks = describe "SimpleX links obfuscated with whitespace" do + let addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw" + inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" + let spaced s = T.replace "://" ":// " s -- insert a space right after the scheme + it "detects links split with spaces or newlines" do + hasObfuscatedSimplexLink addr `shouldBe` True + hasObfuscatedSimplexLink (spaced addr) `shouldBe` True + hasObfuscatedSimplexLink (T.intercalate "\n" $ T.chunksOf 8 addr) `shouldBe` True + hasObfuscatedSimplexLink ("connect with me: " <> spaced addr) `shouldBe` True + hasObfuscatedSimplexLink (T.intercalate " " $ T.chunksOf 8 $ "https://simplex.chat" <> inv) `shouldBe` True + it "detects a split link followed by other text" do + hasObfuscatedSimplexLink (spaced addr <> "\nplease connect") `shouldBe` True + it "ignores text without a SimpleX link" do + hasObfuscatedSimplexLink "" `shouldBe` False + hasObfuscatedSimplexLink "hello there, this is a normal message" `shouldBe` False + hasObfuscatedSimplexLink "see https://example.com/page?ref=123 for details" `shouldBe` False + email :: Text -> Markdown email = Markdown $ Just Email