diff --git a/README.md b/README.md
index 561a322c62..d4103fd104 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/simplex-chat/simplex-chat/releases)
[](https://github.com/simplex-chat/simplex-chat/releases)
[](https://www.reddit.com/r/SimpleXChat)
-[](https://mastodon.social/@simplex)
+
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
@@ -15,7 +15,7 @@
## Welcome to SimpleX Chat!
1. 📲 [Install the app](#install-the-app).
-2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
+2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
@@ -40,14 +40,22 @@
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
-## Connect to the team via the app
+## Connect to the team
+
+You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to:
- to ask any questions
- to suggest any improvements
- to share anything relevant
+We are replying the questions manually, so it is not instant – it can take up to 24 hours.
+
+If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch.
+
## Join user groups
+You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
+
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
You also can:
@@ -79,7 +87,14 @@ There are groups in other languages, that we have the apps interface translated
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
-You can also join the group created by other users by searching for them via the [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
+## Follow our updates
+
+We publish our updates and releases via:
+
+- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828).
+- SimpleX Chat [team profile](#connect-to-the-team).
+- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss).
+- [mailing list](https://simplex.chat/#join-simplex), very rarely.
## Make a private connection
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 508102a941..db82056c2f 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -78,11 +78,11 @@
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */; };
5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; };
5C9D811A2AA8727A001D49FD /* CryptoFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */; };
- 5C9F83F42A9A7D98009AD0AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83EF2A9A7D98009AD0AA /* libffi.a */; };
- 5C9F83F52A9A7D98009AD0AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F02A9A7D98009AD0AA /* libgmp.a */; };
- 5C9F83F62A9A7D98009AD0AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */; };
- 5C9F83F72A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F22A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h-ghc8.10.7.a */; };
- 5C9F83F82A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F83F32A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h.a */; };
+ 5C9E127E2AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9E12792AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t-ghc8.10.7.a */; };
+ 5C9E127F2AAE62A500C9D8FF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9E127A2AAE62A500C9D8FF /* libgmpxx.a */; };
+ 5C9E12802AAE62A500C9D8FF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9E127B2AAE62A500C9D8FF /* libgmp.a */; };
+ 5C9E12812AAE62A500C9D8FF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9E127C2AAE62A500C9D8FF /* libffi.a */; };
+ 5C9E12822AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9E127D2AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t.a */; };
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; };
5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; };
5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */; };
@@ -338,11 +338,11 @@
5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEncryptionView.swift; sourceTree = ""; };
5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; };
5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoFile.swift; sourceTree = ""; };
- 5C9F83EF2A9A7D98009AD0AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
- 5C9F83F02A9A7D98009AD0AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
- 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
- 5C9F83F22A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h-ghc8.10.7.a"; sourceTree = ""; };
- 5C9F83F32A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h.a"; sourceTree = ""; };
+ 5C9E12792AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t-ghc8.10.7.a"; sourceTree = ""; };
+ 5C9E127A2AAE62A500C9D8FF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 5C9E127B2AAE62A500C9D8FF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 5C9E127C2AAE62A500C9D8FF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 5C9E127D2AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t.a"; sourceTree = ""; };
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; };
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; };
5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = ""; };
@@ -502,13 +502,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5C9F83F82A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h.a in Frameworks */,
+ 5C9E127F2AAE62A500C9D8FF /* libgmpxx.a in Frameworks */,
+ 5C9E12812AAE62A500C9D8FF /* libffi.a in Frameworks */,
+ 5C9E12802AAE62A500C9D8FF /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
- 5C9F83F72A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h-ghc8.10.7.a in Frameworks */,
- 5C9F83F62A9A7D98009AD0AA /* libgmpxx.a in Frameworks */,
- 5C9F83F42A9A7D98009AD0AA /* libffi.a in Frameworks */,
- 5C9F83F52A9A7D98009AD0AA /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
+ 5C9E127E2AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t-ghc8.10.7.a in Frameworks */,
+ 5C9E12822AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -569,11 +569,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5C9F83EF2A9A7D98009AD0AA /* libffi.a */,
- 5C9F83F02A9A7D98009AD0AA /* libgmp.a */,
- 5C9F83F12A9A7D98009AD0AA /* libgmpxx.a */,
- 5C9F83F22A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h-ghc8.10.7.a */,
- 5C9F83F32A9A7D98009AD0AA /* libHSsimplex-chat-5.3.0.6-5utBXdHr6QFBiN3wb3H70h.a */,
+ 5C9E127C2AAE62A500C9D8FF /* libffi.a */,
+ 5C9E127B2AAE62A500C9D8FF /* libgmp.a */,
+ 5C9E127A2AAE62A500C9D8FF /* libgmpxx.a */,
+ 5C9E12792AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t-ghc8.10.7.a */,
+ 5C9E127D2AAE62A500C9D8FF /* libHSsimplex-chat-5.3.0.7-6JlIR0UqFTrEzd5R0Y6B8t.a */,
);
path = Libraries;
sourceTree = "";
@@ -1475,7 +1475,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 169;
+ CURRENT_PROJECT_VERSION = 170;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1517,7 +1517,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 169;
+ CURRENT_PROJECT_VERSION = 170;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1597,7 +1597,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 169;
+ CURRENT_PROJECT_VERSION = 170;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1629,7 +1629,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 169;
+ CURRENT_PROJECT_VERSION = 170;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1661,7 +1661,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 169;
+ CURRENT_PROJECT_VERSION = 170;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1707,7 +1707,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 169;
+ CURRENT_PROJECT_VERSION = 170;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt
index c24ade47d9..ebc1b416b5 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt
@@ -1,6 +1,5 @@
package chat.simplex.common.platform
-import android.app.Application
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
@@ -8,10 +7,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import androidx.compose.runtime.*
-import chat.simplex.res.MR
-import chat.simplex.common.model.ChatItem
+import chat.simplex.common.model.*
import chat.simplex.common.platform.AudioPlayer.duration
import chat.simplex.common.views.helpers.*
+import chat.simplex.res.MR
import kotlinx.coroutines.*
import java.io.*
@@ -134,20 +133,25 @@ actual object AudioPlayer: AudioPlayerInterface {
}
// Returns real duration of the track
- private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
- if (!File(filePath).exists()) {
- Log.e(TAG, "No such file: $filePath")
+ private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
+ val absoluteFilePath = getAppFilePath(fileSource.filePath)
+ if (!File(absoluteFilePath).exists()) {
+ Log.e(TAG, "No such file: ${fileSource.filePath}")
return null
}
VideoPlayer.stopAll()
RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value
- if (current == null || current.first != filePath) {
+ if (current == null || current.first != fileSource.filePath) {
stopListener()
player.reset()
runCatching {
- player.setDataSource(filePath)
+ if (fileSource.cryptoArgs != null) {
+ player.setDataSource(CryptoMediaSource(readCryptoFile(absoluteFilePath, fileSource.cryptoArgs)))
+ } else {
+ player.setDataSource(absoluteFilePath)
+ }
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
@@ -162,7 +166,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
if (seek != null) player.seekTo(seek)
player.start()
- currentlyPlaying.value = filePath to onProgressUpdate
+ currentlyPlaying.value = fileSource.filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) {
@@ -229,7 +233,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
override fun play(
- filePath: String?,
+ fileSource: CryptoFile,
audioPlaying: MutableState,
progress: MutableState,
duration: MutableState,
@@ -238,7 +242,7 @@ actual object AudioPlayer: AudioPlayerInterface {
if (progress.value == duration.value) {
progress.value = 0
}
- val realDuration = start(filePath ?: return, progress.value) { pro, state ->
+ val realDuration = start(fileSource, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
@@ -283,3 +287,21 @@ actual object AudioPlayer: AudioPlayerInterface {
}
actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer
+
+class CryptoMediaSource(val data: ByteArray) : MediaDataSource() {
+ override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
+ if (position >= data.size) return -1
+
+ val endPosition: Int = (position + size).toInt()
+ var sizeLeft: Int = size
+ if (endPosition > data.size) {
+ sizeLeft -= endPosition - data.size
+ }
+
+ System.arraycopy(data, position.toInt(), buffer, offset, sizeLeft)
+ return sizeLeft
+ }
+
+ override fun getSize(): Long = data.size.toLong()
+ override fun close() {}
+}
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt
index 811974b2d5..a370bbf405 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt
@@ -8,13 +8,15 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
-import chat.simplex.common.helpers.toUri
-import chat.simplex.common.model.CIFile
-import chat.simplex.common.views.helpers.generalGetString
-import chat.simplex.common.views.helpers.getAppFileUri
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+import chat.simplex.common.helpers.*
+import chat.simplex.common.model.*
+import chat.simplex.common.views.helpers.*
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
+import java.io.ByteArrayOutputStream
actual fun ClipboardManager.shareText(text: String) {
val sendIntent: Intent = Intent().apply {
@@ -28,9 +30,17 @@ actual fun ClipboardManager.shareText(text: String) {
androidAppContext.startActivity(shareIntent)
}
-actual fun shareFile(text: String, filePath: String) {
- val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
- val ext = filePath.substringAfterLast(".")
+actual fun shareFile(text: String, fileSource: CryptoFile) {
+ val uri = if (fileSource.cryptoArgs != null) {
+ val tmpFile = File(tmpDir, fileSource.filePath)
+ tmpFile.deleteOnExit()
+ ChatModel.filesToDelete.add(tmpFile)
+ decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath)
+ FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI()
+ } else {
+ getAppFileUri(fileSource.filePath)
+ }
+ val ext = fileSource.filePath.substringAfterLast(".")
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
@@ -84,8 +94,16 @@ fun saveImage(ciFile: CIFile?) {
uri?.let {
androidAppContext.contentResolver.openOutputStream(uri)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
- File(filePath).inputStream().use { it.copyTo(outputStream) }
- outputStream.close()
+ if (ciFile.fileSource?.cryptoArgs != null) {
+ createTmpFileAndDelete { tmpFile ->
+ decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
+ tmpFile.inputStream().use { it.copyTo(outputStream) }
+ }
+ outputStream.close()
+ } else {
+ File(filePath).inputStream().use { it.copyTo(outputStream) }
+ outputStream.close()
+ }
showToast(generalGetString(MR.strings.image_saved))
}
}
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt
index dc8e9dd541..28c00ec018 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt
@@ -19,7 +19,7 @@ import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
- uri: URI,
+ data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -27,7 +27,7 @@ actual fun SimpleAndAnimatedImageView(
) {
val context = LocalContext.current
val imagePainter = rememberAsyncImagePainter(
- ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(),
+ ImageRequest.Builder(context).data(data = data).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
)
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt
index 15421299a8..8bb70c4a09 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt
@@ -26,7 +26,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
- ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
+ ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt
index d23ee58db2..ade538a044 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt
@@ -26,7 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
-actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
+actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
val imageLoader = ImageLoader.Builder(LocalContext.current)
@@ -40,7 +40,7 @@ actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageB
.build()
Image(
rememberAsyncImagePainter(
- ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(),
+ ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
),
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
index 67c41c3d79..e3c857716d 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
@@ -1,6 +1,5 @@
package chat.simplex.common.views.helpers
-import android.app.Application
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
@@ -12,11 +11,8 @@ import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Base64
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.BaselineShift
@@ -159,17 +155,18 @@ actual fun getAppFileUri(fileName: String): URI =
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI()
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
-actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
+actual fun getLoadedImage(file: CIFile?): Pair? {
val filePath = getLoadedFilePath(file)
- return if (filePath != null) {
+ return if (filePath != null && file != null) {
try {
- val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
- val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r")
- val fileDescriptor = parcelFileDescriptor?.fileDescriptor
- val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
- parcelFileDescriptor?.close()
- image.asImageBitmap()
+ val data = if (file.fileSource?.cryptoArgs != null) {
+ readCryptoFile(getAppFilePath(file.fileSource.filePath), file.fileSource.cryptoArgs)
+ } else {
+ File(getAppFilePath(file.fileName)).readBytes()
+ }
+ decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data
} catch (e: Exception) {
+ Log.e(TAG, e.stackTraceToString())
null
}
} else {
@@ -178,17 +175,17 @@ actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
-private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
+private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
- BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
+ BitmapFactory.decodeByteArray(data, 0, data.size)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
- BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
+ BitmapFactory.decodeByteArray(data, 0, data.size)
}
}
@@ -254,6 +251,26 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma
}?.asImageBitmap()
}
+actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? {
+ return if (Build.VERSION.SDK_INT >= 31) {
+ val source = ImageDecoder.createSource(data)
+ try {
+ ImageDecoder.decodeBitmap(source)
+ } catch (e: android.graphics.ImageDecoder.DecodeException) {
+ Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
+ if (withAlertOnException) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(MR.strings.image_decoding_exception_title),
+ text = generalGetString(MR.strings.image_decoding_exception_desc)
+ )
+ }
+ null
+ }
+ } else {
+ BitmapFactory.decodeByteArray(data, 0, data.size)
+ }?.asImageBitmap()
+}
+
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
return if (Build.VERSION.SDK_INT >= 28) {
val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri())
diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c
index 7b6c032c8a..eb4714710c 100644
--- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c
+++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c
@@ -1,5 +1,6 @@
#include
-//#include
+#include
+#include
//#include
//#include
@@ -45,6 +46,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
+extern char *chat_write_file(const char *path, char *ptr, int length);
+extern char *chat_read_file(const char *path, const char *key, const char *nonce);
+extern char *chat_encrypt_file(const char *from_path, const char *to_path);
+extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
@@ -115,3 +120,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}
+
+JNIEXPORT jstring JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
+ const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
+ jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
+ jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
+ jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity));
+ (*env)->ReleaseStringUTFChars(env, path, _path);
+ return res;
+}
+
+JNIEXPORT jobjectArray JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) {
+ const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
+ const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
+ const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
+
+ jbyte *res = chat_read_file(_path, _key, _nonce);
+ (*env)->ReleaseStringUTFChars(env, path, _path);
+ (*env)->ReleaseStringUTFChars(env, key, _key);
+ (*env)->ReleaseStringUTFChars(env, nonce, _nonce);
+
+ jint status = (jint)res[0];
+ jbyteArray arr;
+ if (status == 0) {
+ union {
+ uint32_t w;
+ uint8_t b[4];
+ } len;
+ len.b[0] = (uint8_t)res[1];
+ len.b[1] = (uint8_t)res[2];
+ len.b[2] = (uint8_t)res[3];
+ len.b[3] = (uint8_t)res[4];
+ arr = (*env)->NewByteArray(env, len.w);
+ (*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5);
+ } else {
+ int len = strlen(res + 1); // + 1 offset here is to not include status byte
+ arr = (*env)->NewByteArray(env, len);
+ (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
+ }
+
+ jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
+ jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
+ (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "", "(I)V"),
+ status);
+ (*env)->SetObjectArrayElement(env, ret, 0, statusObj);
+ (*env)->SetObjectArrayElement(env, ret, 1, arr);
+ return ret;
+}
+
+JNIEXPORT jstring JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) {
+ const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
+ const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
+ jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path));
+ (*env)->ReleaseStringUTFChars(env, from_path, _from_path);
+ (*env)->ReleaseStringUTFChars(env, to_path, _to_path);
+ return res;
+}
+
+JNIEXPORT jstring JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) {
+ const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
+ const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
+ const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
+ const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
+ jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
+ (*env)->ReleaseStringUTFChars(env, from_path, _from_path);
+ (*env)->ReleaseStringUTFChars(env, key, _key);
+ (*env)->ReleaseStringUTFChars(env, nonce, _nonce);
+ (*env)->ReleaseStringUTFChars(env, to_path, _to_path);
+ return res;
+}
diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c
index 8e869ca2d9..ddc5c92f93 100644
--- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c
+++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c
@@ -1,6 +1,7 @@
#include
#include
#include
+#include
// from the RTS
void hs_init(int * argc, char **argv[]);
@@ -20,7 +21,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
-
+extern char *chat_write_file(const char *path, char *ptr, int length);
+extern char *chat_read_file(const char *path, const char *key, const char *nonce);
+extern char *chat_encrypt_file(const char *from_path, const char *to_path);
+extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
// As a reference: https://stackoverflow.com/a/60002045
jstring decode_to_utf8_string(JNIEnv *env, char *string) {
@@ -128,3 +132,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}
+
+JNIEXPORT jstring JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
+ const char *_path = encode_to_utf8_chars(env, path);
+ jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
+ jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
+ jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity));
+ (*env)->ReleaseStringUTFChars(env, path, _path);
+ return res;
+}
+
+JNIEXPORT jobjectArray JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) {
+ const char *_path = encode_to_utf8_chars(env, path);
+ const char *_key = encode_to_utf8_chars(env, key);
+ const char *_nonce = encode_to_utf8_chars(env, nonce);
+
+ jbyte *res = chat_read_file(_path, _key, _nonce);
+ (*env)->ReleaseStringUTFChars(env, path, _path);
+ (*env)->ReleaseStringUTFChars(env, key, _key);
+ (*env)->ReleaseStringUTFChars(env, nonce, _nonce);
+
+ jint status = (jint)res[0];
+ jbyteArray arr;
+ if (status == 0) {
+ union {
+ uint32_t w;
+ uint8_t b[4];
+ } len;
+ len.b[0] = (uint8_t)res[1];
+ len.b[1] = (uint8_t)res[2];
+ len.b[2] = (uint8_t)res[3];
+ len.b[3] = (uint8_t)res[4];
+ arr = (*env)->NewByteArray(env, len.w);
+ (*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5);
+ } else {
+ int len = strlen(res + 1); // + 1 offset here is to not include status byte
+ arr = (*env)->NewByteArray(env, len);
+ (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
+ }
+
+ jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
+ jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
+ (*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "", "(I)V"),
+ status);
+ (*env)->SetObjectArrayElement(env, ret, 0, statusObj);
+ (*env)->SetObjectArrayElement(env, ret, 1, arr);
+ return ret;
+}
+
+JNIEXPORT jstring JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) {
+ const char *_from_path = encode_to_utf8_chars(env, from_path);
+ const char *_to_path = encode_to_utf8_chars(env, to_path);
+ jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path));
+ (*env)->ReleaseStringUTFChars(env, from_path, _from_path);
+ (*env)->ReleaseStringUTFChars(env, to_path, _to_path);
+ return res;
+}
+
+JNIEXPORT jstring JNICALL
+Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) {
+ const char *_from_path = encode_to_utf8_chars(env, from_path);
+ const char *_key = encode_to_utf8_chars(env, key);
+ const char *_nonce = encode_to_utf8_chars(env, nonce);
+ const char *_to_path = encode_to_utf8_chars(env, to_path);
+ jstring res = decode_to_utf8_string(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
+ (*env)->ReleaseStringUTFChars(env, from_path, _from_path);
+ (*env)->ReleaseStringUTFChars(env, key, _key);
+ (*env)->ReleaseStringUTFChars(env, nonce, _nonce);
+ (*env)->ReleaseStringUTFChars(env, to_path, _to_path);
+ return res;
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index df1d8dce78..429bbd001d 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -13,6 +13,7 @@ import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.platform.AudioPlayer
+import chat.simplex.common.platform.chatController
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@@ -1394,6 +1395,13 @@ data class ChatItem (
private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
+ val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
+
+ val encryptLocalFile: Boolean
+ get() = file?.fileProtocol == FileProtocol.XFTP &&
+ content.msgContent !is MsgContent.MCVideo &&
+ chatController.appPrefs.privacyEncryptLocalFiles.get()
+
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
else null
@@ -2099,7 +2107,7 @@ class CIFile(
}
@Serializable
-class CryptoFile(
+data class CryptoFile(
val filePath: String,
val cryptoArgs: CryptoFileArgs?
) {
@@ -2109,7 +2117,7 @@ class CryptoFile(
}
@Serializable
-class CryptoFileArgs(val fileKey: String, val fileNonce: String)
+data class CryptoFileArgs(val fileKey: String, val fileNonce: String)
class CancelAction(
val uiActionId: StringResource,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt
new file mode 100644
index 0000000000..037d27af33
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt
@@ -0,0 +1,59 @@
+package chat.simplex.common.model
+
+import chat.simplex.common.platform.*
+import kotlinx.serialization.*
+import java.nio.ByteBuffer
+
+@Serializable
+sealed class WriteFileResult {
+ @Serializable @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult()
+ @Serializable @SerialName("error") data class Error(val writeError: String): WriteFileResult()
+}
+
+/*
+ fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
+ val str = chatWriteFile(path, data)
+ return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
+ is WriteFileResult.Result -> d.cryptoArgs
+ is WriteFileResult.Error -> throw Exception(d.writeError)
+ }
+}
+* */
+
+fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
+ val buffer = ByteBuffer.allocateDirect(data.size)
+ buffer.put(data)
+ buffer.rewind()
+ val str = chatWriteFile(path, buffer)
+ return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
+ is WriteFileResult.Result -> d.cryptoArgs
+ is WriteFileResult.Error -> throw Exception(d.writeError)
+ }
+}
+
+fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray {
+ val res: Array = chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce)
+ val status = (res[0] as Integer).toInt()
+ val arr = res[1] as ByteArray
+ if (status == 0) {
+ return arr
+ } else {
+ throw Exception(String(arr))
+ }
+}
+
+fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs {
+ val str = chatEncryptFile(fromPath, toPath)
+ val d = json.decodeFromString(WriteFileResult.serializer(), str)
+ return when (d) {
+ is WriteFileResult.Result -> d.cryptoArgs
+ is WriteFileResult.Error -> throw Exception(d.writeError)
+ }
+}
+
+fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) {
+ val err = chatDecryptFile(fromPath, cryptoArgs.fileKey, cryptoArgs.fileNonce, toPath)
+ if (err != "") {
+ throw Exception(err)
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index 0ad471b57f..534544c055 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import dev.icerock.moko.resources.compose.painterResource
-import chat.simplex.common.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
@@ -94,6 +93,7 @@ class AppPreferences {
val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true)
val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true)
val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false)
+ val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -249,6 +249,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
+ private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
@@ -1413,8 +1414,7 @@ object ChatController {
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
- // TODO encrypt images and voice
- withApi { receiveFile(r.user, file.fileId, encrypted = false, auto = true) }
+ withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) }
}
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt
index fff77ee23b..d36a6aec16 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt
@@ -1,8 +1,9 @@
package chat.simplex.common.platform
import chat.simplex.common.BuildConfigCommon
-import chat.simplex.common.model.ChatController
+import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.DefaultTheme
+import java.io.File
import java.util.*
enum class AppPlatform {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt
index 341f4e9548..801a0270e2 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt
@@ -4,6 +4,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import kotlinx.serialization.decodeFromString
+import java.nio.ByteBuffer
// ghc's rts
external fun initHS()
@@ -19,6 +20,10 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String
+external fun chatWriteFile(path: String, buffer: ByteBuffer): String
+external fun chatReadFile(path: String, key: String, nonce: String): Array
+external fun chatEncryptFile(fromPath: String, toPath: String): String
+external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String
val chatModel: ChatModel
get() = chatController.chatModel
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt
index 53b0f8bd96..71a9f204f8 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt
@@ -2,6 +2,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import chat.simplex.common.model.CIFile
+import chat.simplex.common.model.CryptoFile
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import java.io.*
@@ -71,6 +72,16 @@ fun getLoadedFilePath(file: CIFile?): String? {
}
}
+fun getLoadedFileSource(file: CIFile?): CryptoFile? {
+ val f = file?.fileSource?.filePath
+ return if (f != null && file.loaded) {
+ val filePath = getAppFilePath(f)
+ if (File(filePath).exists()) file.fileSource else null
+ } else {
+ null
+ }
+}
+
/**
* [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function
* */
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt
index bbc5cbe667..2d6bb2a371 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt
@@ -1,7 +1,7 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
-import chat.simplex.common.model.ChatItem
+import chat.simplex.common.model.*
import kotlinx.coroutines.CoroutineScope
interface RecorderInterface {
@@ -18,7 +18,7 @@ expect class RecorderNative(): RecorderInterface
interface AudioPlayerInterface {
fun play(
- filePath: String?,
+ fileSource: CryptoFile,
audioPlaying: MutableState,
progress: MutableState,
duration: MutableState,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt
index 03ad4b5441..72bb3caaac 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt
@@ -2,8 +2,9 @@ package chat.simplex.common.platform
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
+import chat.simplex.common.model.CryptoFile
expect fun UriHandler.sendEmail(subject: String, body: CharSequence)
expect fun ClipboardManager.shareText(text: String)
-expect fun shareFile(text: String, filePath: String)
+expect fun shareFile(text: String, fileSource: CryptoFile)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 6d26e3e189..e1bdbef865 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -1117,7 +1117,7 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha
}
sealed class ProviderMedia {
- data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia()
+ data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia()
data class Video(val uri: URI, val preview: String): ProviderMedia()
}
@@ -1155,11 +1155,11 @@ private fun providerForGallery(
val item = item(internalIndex, initialChatId)?.second ?: return null
return when (item.content.msgContent) {
is MsgContent.MCImage -> {
- val imageBitmap: ImageBitmap? = getLoadedImage(item.file)
+ val res = getLoadedImage(item.file)
val filePath = getLoadedFilePath(item.file)
- if (imageBitmap != null && filePath != null) {
- val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
- ProviderMedia.Image(uri, imageBitmap)
+ if (res != null && filePath != null) {
+ val (imageBitmap: ImageBitmap, data: ByteArray) = res
+ ProviderMedia.Image(data, imageBitmap)
} else null
}
is MsgContent.MCVideo -> {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
index aa7a0e5f43..743f23cf69 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
@@ -420,8 +420,8 @@ fun ComposeView(
is ComposePreview.MediaPreview -> {
preview.content.forEachIndexed { index, it ->
val file = when (it) {
- is UploadContent.SimpleImage -> saveImage(it.uri)
- is UploadContent.AnimatedImage -> saveAnimImage(it.uri)
+ is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
+ is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false)
}
if (file != null) {
@@ -438,16 +438,21 @@ fun ComposeView(
val tmpFile = File(preview.voice)
AudioPlayer.stop(tmpFile.absolutePath)
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
- withContext(Dispatchers.IO) {
- Files.move(tmpFile.toPath(), actualFile.toPath())
- }
- // TODO encrypt voice files
- files.add(CryptoFile.plain(actualFile.name))
+ files.add(withContext(Dispatchers.IO) {
+ if (chatController.appPrefs.privacyEncryptLocalFiles.get()) {
+ val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath)
+ tmpFile.delete()
+ CryptoFile(actualFile.name, args)
+ } else {
+ Files.move(tmpFile.toPath(), actualFile.toPath())
+ CryptoFile.plain(actualFile.name)
+ }
+ })
deleteUnusedFiles()
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
}
is ComposePreview.FilePreview -> {
- val file = saveFileFromUri(preview.uri, encrypted = false)
+ val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt
index 99d7de96be..a4c90d30dd 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt
@@ -17,6 +17,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import chat.simplex.common.model.CryptoFile
import chat.simplex.common.model.durationText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -52,7 +53,7 @@ fun ComposeVoiceView(
IconButton(
onClick = {
if (!audioPlaying.value) {
- AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
+ AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
index 4642600fcb..8de805ba54 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
@@ -71,7 +71,8 @@ fun CIFileView(
when (file.fileStatus) {
is CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
- receiveFile(file.fileId, false)
+ val encrypted = file.fileProtocol == FileProtocol.XFTP && chatController.appPrefs.privacyEncryptLocalFiles.get()
+ receiveFile(file.fileId, encrypted)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -184,9 +185,9 @@ fun CIFileView(
) {
fileIndicator()
val metaReserve = if (edited)
- " "
+ " "
else
- " "
+ " "
if (file != null) {
Column {
Text(
@@ -211,7 +212,15 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
val filePath = getLoadedFilePath(ciFile)
if (filePath != null && to != null) {
- copyFileToFile(File(filePath), to) {}
+ if (ciFile?.fileSource?.cryptoArgs != null) {
+ createTmpFileAndDelete { tmpFile ->
+ decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
+ copyFileToFile(tmpFile, to) {}
+ tmpFile.delete()
+ }
+ } else {
+ copyFileToFile(File(filePath), to) {}
+ }
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
index 75d6a9c304..23d1f1d0cc 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
@@ -29,6 +29,8 @@ import java.net.URI
fun CIImageView(
image: String,
file: CIFile?,
+ encryptLocalFile: Boolean,
+ metaColor: Color,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState,
receiveFile: (Long, Boolean) -> Unit
@@ -48,7 +50,7 @@ fun CIImageView(
icon,
stringResource(stringId),
Modifier.fillMaxSize(),
- tint = Color.White
+ tint = metaColor
)
}
@@ -132,28 +134,31 @@ fun CIImageView(
return false
}
- fun imageAndFilePath(file: CIFile?): Pair {
- val imageBitmap: ImageBitmap? = getLoadedImage(file)
- val filePath = getLoadedFilePath(file)
- return imageBitmap to filePath
+ fun imageAndFilePath(file: CIFile?): Triple? {
+ val res = getLoadedImage(file)
+ if (res != null) {
+ val (imageBitmap: ImageBitmap, data: ByteArray) = res
+ val filePath = getLoadedFilePath(file)!!
+ return Triple(imageBitmap, data, filePath)
+ }
+ return null
}
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd
) {
- val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
- if (imageBitmap != null && filePath != null) {
- val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
- SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
+ val res = remember(file) { imageAndFilePath(file) }
+ if (res != null) {
+ val (imageBitmap, data, _) = res
+ SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
if (fileSizeValid()) {
- // TODO encrypt image
- receiveFile(file.fileId, false)
+ receiveFile(file.fileId, encryptLocalFile)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -187,7 +192,7 @@ fun CIImageView(
@Composable
expect fun SimpleAndAnimatedImageView(
- uri: URI,
+ data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt
index ab121c6272..72f7137b55 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt
@@ -44,14 +44,14 @@ fun CIMetaView(
modifier = Modifier.padding(start = 3.dp)
)
} else {
- CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor)
+ CIMetaText(chatItem.meta, timedMessagesTTL, encrypted = chatItem.encryptedFile, metaColor, paleMetaColor)
}
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
-private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) {
+private fun CIMetaText(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, color: Color, paleColor: Color) {
if (meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
Spacer(Modifier.width(3.dp))
@@ -77,11 +77,15 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Col
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
Spacer(Modifier.width(4.dp))
}
+ if (encrypted != null) {
+ StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
+ Spacer(Modifier.width(4.dp))
+ }
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
// the conditions in this function should match CIMetaText
-fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
+fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): String {
val iconSpace = " "
var res = ""
if (meta.itemEdited) res += iconSpace
@@ -95,6 +99,9 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
res += iconSpace
}
+ if (encrypted != null) {
+ res += iconSpace
+ }
return res + meta.timestampText
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt
index 2918d885b1..8de309fc8f 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt
@@ -166,7 +166,7 @@ fun DecryptionErrorItemFixButton(
Text(
buildAnnotatedString {
append(generalGetString(MR.strings.fix_connection))
- withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
+ withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
withStyle(reserveTimestampStyle) { append(" ") } // for icon
},
color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
@@ -196,7 +196,7 @@ fun DecryptionErrorItem(
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
- withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
+ withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt
index 6ec39bb4f0..941bc315b6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt
@@ -20,8 +20,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
-import chat.simplex.common.platform.getLoadedFilePath
-import chat.simplex.common.platform.AudioPlayer
+import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -45,14 +44,16 @@ fun CIVoiceView(
) {
if (file != null) {
val f = file.fileSource?.filePath
- val filePath = remember(f, file.fileStatus) { getLoadedFilePath(file) }
+ val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) }
var brokenAudio by rememberSaveable(f) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(f) { mutableStateOf(false) }
val progress = rememberSaveable(f) { mutableStateOf(0) }
val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) }
val play = {
- AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
- brokenAudio = !audioPlaying.value
+ if (fileSource != null) {
+ AudioPlayer.play(fileSource, audioPlaying, progress, duration, true)
+ brokenAudio = !audioPlaying.value
+ }
}
val pause = {
AudioPlayer.pause(audioPlaying, progress)
@@ -67,7 +68,7 @@ fun CIVoiceView(
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
- AudioPlayer.seekTo(it, progress, filePath)
+ AudioPlayer.seekTo(it, progress, fileSource?.filePath)
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)
@@ -269,8 +270,7 @@ private fun VoiceMsgIndicator(
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
- // TODO encrypt voice
- PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, false) }, {}, longClick = longClick)
+ PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, chatController.appPrefs.privacyEncryptLocalFiles.get()) }, {}, longClick = longClick)
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
|| file?.fileStatus is CIFileStatus.RcvAccepted
) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt
index cc2d97e3f5..60ef7e8cfe 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt
@@ -191,9 +191,9 @@ fun ChatItemView(
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
- val filePath = getLoadedFilePath(cItem.file)
+ val fileSource = getLoadedFileSource(cItem.file)
when {
- filePath != null -> shareFile(cItem.text, filePath)
+ fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
}
showMenu.value = false
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt
index 92cf62a855..122e54c3b2 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt
@@ -226,7 +226,7 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
- CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
+ CIImageView(image = mc.image, file = ci.file, ci.encryptLocalFile, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt
index 270c671fc1..9664cabc41 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt
@@ -123,8 +123,8 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
// LALAL
// https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24
if (media is ProviderMedia.Image) {
- val (uri: URI, imageBitmap: ImageBitmap) = media
- FullScreenImageView(modifier, uri, imageBitmap)
+ val (data: ByteArray, imageBitmap: ImageBitmap) = media
+ FullScreenImageView(modifier, data, imageBitmap)
} else if (media is ProviderMedia.Video) {
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
@@ -138,7 +138,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
@Composable
-expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap)
+expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
@Composable
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
index 64855e3195..eabab138ba 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
@@ -76,7 +76,7 @@ fun MarkdownText (
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
- reserveSpaceForMeta(meta, chatTTL)
+ reserveSpaceForMeta(meta, chatTTL, null) // LALAL
} else {
" "
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt
index 9ecd7dae31..fa0f8f54d1 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt
@@ -178,7 +178,7 @@ fun DatabaseLayout(
SectionView(stringResource(MR.strings.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
- if (unencrypted) painterResource(MR.images.ic_lock_open) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
+ if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
else painterResource(MR.images.ic_lock),
stringResource(MR.strings.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
index b9eeee12bc..6aaf7a9fdf 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
@@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
expect fun getAppFileUri(fileName: String): URI
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
-expect fun getLoadedImage(file: CIFile?): ImageBitmap?
+expect fun getLoadedImage(file: CIFile?): Pair?
expect fun getFileName(uri: URI): String?
@@ -77,6 +77,8 @@ expect fun getFileSize(uri: URI): Long?
expect fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean = true): ImageBitmap?
+expect fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap?
+
expect fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean = true): Any?
fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverrides? {
@@ -95,31 +97,34 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri
return null
}
-fun saveImage(uri: URI): CryptoFile? {
+fun saveImage(uri: URI, encrypted: Boolean): CryptoFile? {
val bitmap = getBitmapFromUri(uri) ?: return null
- return saveImage(bitmap)
+ return saveImage(bitmap, encrypted)
}
-fun saveImage(image: ImageBitmap): CryptoFile? {
- // TODO encrypt image
+fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? {
return try {
val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
- val fileToSave = generateNewFileName("IMG", ext)
- val file = File(getAppFilePath(fileToSave))
- val output = FileOutputStream(file)
- dataResized.writeTo(output)
- output.flush()
- output.close()
- CryptoFile.plain(fileToSave)
+ val destFileName = generateNewFileName("IMG", ext)
+ val destFile = File(getAppFilePath(destFileName))
+ if (encrypted) {
+ val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray())
+ CryptoFile(destFileName, args)
+ } else {
+ val output = FileOutputStream(destFile)
+ dataResized.writeTo(output)
+ output.flush()
+ output.close()
+ CryptoFile.plain(destFileName)
+ }
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}")
null
}
}
-fun saveAnimImage(uri: URI): CryptoFile? {
- // TODO encrypt image
+fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val filename = getFileName(uri)?.lowercase()
var ext = when {
@@ -129,15 +134,15 @@ fun saveAnimImage(uri: URI): CryptoFile? {
}
// Just in case the image has a strange extension
if (ext.length < 3 || ext.length > 4) ext = "gif"
- val fileToSave = generateNewFileName("IMG", ext)
- val file = File(getAppFilePath(fileToSave))
- val output = FileOutputStream(file)
- uri.inputStream().use { input ->
- output.use { output ->
- input?.copyTo(output)
- }
+ val destFileName = generateNewFileName("IMG", ext)
+ val destFile = File(getAppFilePath(destFileName))
+ if (encrypted) {
+ val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null)
+ CryptoFile(destFileName, args)
+ } else {
+ Files.copy(uri.inputStream(), destFile.toPath())
+ CryptoFile.plain(destFileName)
}
- CryptoFile.plain(fileToSave)
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}")
null
@@ -150,22 +155,40 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val inputStream = uri.inputStream()
val fileToSave = getFileName(uri)
- // TODO encrypt file if "encrypted" is true
- if (inputStream != null && fileToSave != null) {
+ return if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(fileToSave)
val destFile = File(getAppFilePath(destFileName))
- Files.copy(inputStream, destFile.toPath())
- CryptoFile.plain(destFileName)
+ if (encrypted) {
+ createTmpFileAndDelete { tmpFile ->
+ Files.copy(inputStream, tmpFile.toPath())
+ val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath)
+ CryptoFile(destFileName, args)
+ }
+ } else {
+ Files.copy(inputStream, destFile.toPath())
+ CryptoFile.plain(destFileName)
+ }
} else {
Log.e(TAG, "Util.kt saveFileFromUri null inputStream")
null
}
} catch (e: Exception) {
- Log.e(TAG, "Util.kt saveFileFromUri error: ${e.message}")
+ Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}")
null
}
}
+fun createTmpFileAndDelete(onCreated: (File) -> T): T {
+ val tmpFile = File(tmpDir, UUID.randomUUID().toString())
+ tmpFile.deleteOnExit()
+ ChatModel.filesToDelete.add(tmpFile)
+ try {
+ return onCreated(tmpFile)
+ } finally {
+ tmpFile.delete()
+ }
+}
+
fun generateNewFileName(prefix: String, ext: String): String {
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("GMT")
@@ -266,6 +289,17 @@ fun blendARGB(
return Color(r, g, b, a)
}
+fun InputStream.toByteArray(): ByteArray =
+ ByteArrayOutputStream().use { output ->
+ val b = ByteArray(4096)
+ var n = read(b)
+ while (n != -1) {
+ output.write(b, 0, n);
+ n = read(b)
+ }
+ return output.toByteArray()
+ }
+
expect fun ByteArray.toBase64StringForPassphrase(): String
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt
index a848d3777b..6632925964 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt
@@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import boofcv.alg.drawing.FiducialImageEngine
import boofcv.alg.fiducial.qrcode.*
+import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
@@ -45,7 +46,7 @@ fun QRCode(
.let { if (withLogo) it.addLogo() else it }
val file = saveTempImageUncompressed(image, false)
if (file != null) {
- shareFile("", file.absolutePath)
+ shareFile("", CryptoFile.plain(file.absolutePath))
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt
index 447b65eff6..f2fee926a7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt
@@ -180,7 +180,13 @@ fun NetworkAndServersView(
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
if (networkUseSocksProxy.value) {
- SectionCustomFooter { Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) }
+ SectionCustomFooter {
+ Column {
+ Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported))
+ Spacer(Modifier.height(DEFAULT_PADDING_HALF))
+ Text(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
+ }
+ }
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
} else {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt
index 81d56a3816..ef0940b2a0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt
@@ -64,6 +64,7 @@ fun PrivacySettingsView(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_chats)) {
+ SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles)
SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SettingsPreferenceItem(
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt
index ac3a68fc49..7929413c93 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt
@@ -164,10 +164,9 @@ private fun UserProfilesLayout(
) {
if (profileHidden.value) {
SectionView {
- SettingsActionItem(painterResource(MR.images.ic_lock_open), stringResource(MR.strings.enter_password_to_show), click = {
+ SettingsActionItem(painterResource(MR.images.ic_lock_open_right), stringResource(MR.strings.enter_password_to_show), click = {
profileHidden.value = false
- }
- )
+ })
}
SectionSpacer()
}
@@ -223,7 +222,7 @@ private fun UserView(
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
DefaultDropdownMenu(showMenu) {
if (user.hidden) {
- ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open), onClick = {
+ ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open_right), onClick = {
showMenu.value = false
unhideUser(user)
})
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 2b83ff869a..8e035420d5 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -615,7 +615,7 @@
Required
Onion hosts will be used when available.
Onion hosts will not be used.
- Onion hosts will be required for connection.
+ Onion hosts will be required for connection.\nPlease note: you will not be able to connect to the servers without .onion address.
Onion hosts will be used when available.
Onion hosts will not be used.
Onion hosts will be required for connection.
@@ -626,6 +626,7 @@
for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]>
Update transport isolation mode?
Use .onion hosts to No if SOCKS proxy does not support them.]]>
+ Please note: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]>
Appearance
Customize theme
THEME COLORS
@@ -855,6 +856,7 @@
Privacy & security
Your privacy
Protect app screen
+ Encrypt local files
Auto-accept images
Send link previews
Show last messages
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg
deleted file mode 100644
index bf6b7b47b4..0000000000
--- a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg
new file mode 100644
index 0000000000..3188cf798e
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt
index 471389d0cd..612217925b 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt
@@ -25,6 +25,8 @@ fun initApp() {
initChatController()
runMigrations()
}
+ // LALAL
+ //testCrypto()
}
private fun applyAppLocale() {
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt
index 2ba6f3b3f6..6e85ea91c6 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt
@@ -1,7 +1,7 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
-import chat.simplex.common.model.ChatItem
+import chat.simplex.common.model.*
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import kotlinx.coroutines.CoroutineScope
@@ -18,7 +18,7 @@ actual class RecorderNative: RecorderInterface {
}
actual object AudioPlayer: AudioPlayerInterface {
- override fun play(filePath: String?, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) {
+ override fun play(fileSource: CryptoFile, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) {
showInDevelopingAlert()
}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt
index 84e24a1d55..1d5ab45bbb 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt
@@ -3,6 +3,8 @@ package chat.simplex.common.platform
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.AnnotatedString
+import chat.simplex.common.model.*
+import chat.simplex.common.views.helpers.getAppFileUri
import chat.simplex.common.views.helpers.withApi
import java.io.File
import java.net.URI
@@ -20,12 +22,16 @@ actual fun ClipboardManager.shareText(text: String) {
showToast(MR.strings.copied.localized())
}
-actual fun shareFile(text: String, filePath: String) {
+actual fun shareFile(text: String, fileSource: CryptoFile) {
withApi {
FileChooserLauncher(false) { to: URI? ->
if (to != null) {
- copyFileToFile(File(filePath), to) {}
+ if (fileSource.cryptoArgs != null) {
+ decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path)
+ } else {
+ copyFileToFile(File(fileSource.filePath), to) {}
+ }
}
- }.launch(filePath)
+ }.launch(fileSource.filePath)
}
}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt
index 214946b1c9..711e09267d 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt
@@ -11,7 +11,7 @@ import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
- uri: URI,
+ data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt
index 9b265a5f5f..c1d9eeec52 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt
@@ -31,7 +31,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) {
- ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
+ ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") }
else -> {}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt
index e4e483092d..a73c2784ed 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt
@@ -4,19 +4,16 @@ import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
-import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import chat.simplex.common.platform.VideoPlayer
-import chat.simplex.common.views.helpers.getBitmapFromUri
+import chat.simplex.common.views.helpers.getBitmapFromByteArray
import chat.simplex.res.MR
-import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
-import java.net.URI
@Composable
-actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
+actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
Image(
- getBitmapFromUri(uri, false) ?: MR.images.decentralized.image.toComposeImageBitmap(),
+ getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap(),
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Fit,
modifier = modifier,
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt
index f4a9ac9b78..4fa768a5d3 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt
@@ -6,8 +6,10 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import chat.simplex.common.model.CIFile
+import chat.simplex.common.model.readCryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
+import java.io.ByteArrayInputStream
import java.io.File
import java.net.URI
import javax.imageio.ImageIO
@@ -88,11 +90,12 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat
actual fun getAppFileUri(fileName: String): URI =
URI("file:" + appFilesDir.absolutePath + File.separator + fileName)
-actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
+actual fun getLoadedImage(file: CIFile?): Pair? {
val filePath = getLoadedFilePath(file)
return if (filePath != null) {
- val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
- getBitmapFromUri(uri, false)
+ val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes()
+ val bitmap = getBitmapFromByteArray(data, false)
+ if (bitmap != null) bitmap to data else null
} else {
null
}
@@ -107,6 +110,9 @@ actual fun getFileSize(uri: URI): Long? = uri.toPath().toFile().length()
actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? =
ImageIO.read(uri.inputStream()).toComposeImageBitmap()
+actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? =
+ ImageIO.read(ByteArrayInputStream(data)).toComposeImageBitmap()
+
// LALAL implement to support animated drawable
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null
diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties
index e2944a62f0..4352b8eb81 100644
--- a/apps/multiplatform/gradle.properties
+++ b/apps/multiplatform/gradle.properties
@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
-android.version_name=5.3-beta.6
-android.version_code=148
+android.version_name=5.3-beta.7
+android.version_code=149
-desktop.version_name=1.4.0
-desktop.version_code=6
+desktop.version_name=1.5.0
+desktop.version_code=7
kotlin.version=1.8.20
gradle.plugin.version=7.4.2
diff --git a/cabal.project b/cabal.project
index ec72b72fcc..983468726a 100644
--- a/cabal.project
+++ b/cabal.project
@@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
- tag: 351f42650c57f310fc1ea858ff9b7178823f1fd4
+ tag: 0cabe0690beee90f460ad7bada72294222e7e109
source-repository-package
type: git
diff --git a/package.yaml b/package.yaml
index 68e7acda7a..811a6cd8a6 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 5.3.0.6
+version: 5.3.0.7
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix
index dbbc7475c3..493985085a 100644
--- a/scripts/nix/sha256map.nix
+++ b/scripts/nix/sha256map.nix
@@ -1,5 +1,5 @@
{
- "https://github.com/simplex-chat/simplexmq.git"."351f42650c57f310fc1ea858ff9b7178823f1fd4" = "12r13yc0qk9dkii58808862wraqrk66rzmkrgyp6lg1xrazrd0d2";
+ "https://github.com/simplex-chat/simplexmq.git"."0cabe0690beee90f460ad7bada72294222e7e109" = "1yfcrifb2l59wgl14q56ywlil2g2zs57ic62s617whh3w2mnh0kz";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 335e0ee108..35e01e7bca 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: 5.3.0.6
+version: 5.3.0.7
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -110,6 +110,8 @@ library
Simplex.Chat.Migrations.M20230814_indexes
Simplex.Chat.Migrations.M20230827_file_encryption
Simplex.Chat.Migrations.M20230829_connections_chat_vrange
+ Simplex.Chat.Migrations.M20230903_connections_to_subscribe
+ Simplex.Chat.Migrations.M20230904_item_direct_group_member_id
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index 359e7f5b5d..9522d22180 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -89,7 +89,7 @@ import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
-import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), UserProtocol, userProtocol)
+import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport.Client (defaultSocksProxy)
@@ -194,6 +194,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
inputQ <- newTBQueueIO tbqSize
outputQ <- newTBQueueIO tbqSize
notifyQ <- newTBQueueIO tbqSize
+ subscriptionMode <- newTVarIO SMSubscribe
chatLock <- newEmptyTMVarIO
sndFiles <- newTVarIO M.empty
rcvFiles <- newTVarIO M.empty
@@ -207,7 +208,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
showLiveItems <- newTVarIO False
userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg
tempDirectory <- newTVarIO tempDir
- pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile}
+ pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile}
where
configServers :: DefaultAgentServers
configServers =
@@ -246,6 +247,8 @@ cfgServers = \case
startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ())
startChatController subConns enableExpireCIs startXFTPWorkers = do
asks smpAgent >>= resumeAgentClient
+ unless subConns $
+ chatWriteVar subscriptionMode SMOnlyCreate
users <- fromRight [] <$> runExceptT (withStoreCtx' (Just "startChatController, getUsers") getUsers)
restoreCalls
s <- asks agentAsync
@@ -255,7 +258,7 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do
a1 <- async $ race_ notificationSubscriber agentSubscriber
a2 <-
if subConns
- then Just <$> async (subscribeUsers users)
+ then Just <$> async (subscribeUsers False users)
else pure Nothing
atomically . writeTVar s $ Just (a1, a2)
when startXFTPWorkers $ do
@@ -283,14 +286,14 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do
startExpireCIThread user
setExpireCIFlag user True
-subscribeUsers :: forall m. ChatMonad' m => [User] -> m ()
-subscribeUsers users = do
+subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m ()
+subscribeUsers onlyNeeded users = do
let (us, us') = partition activeUser users
subscribe us
subscribe us'
where
subscribe :: [User] -> m ()
- subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections
+ subscribe = mapM_ $ runExceptT . subscribeUserConnections onlyNeeded Agent.subscribeConnections
startFilesToReceive :: forall m. ChatMonad' m => [User] -> m ()
startFilesToReceive users = do
@@ -464,14 +467,16 @@ processChatCommand = \case
APIActivateChat -> withUser $ \_ -> do
restoreCalls
withAgent foregroundAgent
- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers >>= void . forkIO . startFilesToReceive
+ users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers
+ void . forkIO $ subscribeUsers True users
+ void . forkIO $ startFilesToReceive users
setAllExpireCIFlags True
ok_
APISuspendChat t -> do
setAllExpireCIFlags False
withAgent (`suspendAgent` t)
ok_
- ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers >> ok_
+ ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_
-- has to be called before StartChat
SetTempFolder tf -> do
createDirectoryIfMissing True tf
@@ -535,8 +540,8 @@ processChatCommand = \case
memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses
_ -> pure Nothing
pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses}
- APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
- CTDirect -> do
+ APISendMessage sendRef live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case sendRef of
+ SRDirect chatId -> do
ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId
assertDirectAllowed user MDSnd ct XMsgNew_
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
@@ -562,20 +567,21 @@ processChatCommand = \case
(fileSize, fileMode) <- checkSndFile mc file 1
case fileMode of
SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline
- SendFileXFTP -> xftpSndFileTransfer user file fileSize 1 $ CGContact ct
+ SendFileXFTP -> xftpSndFileTransfer user file fileSize 1 $ SDDirect ct
where
smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled
smpSndFileTransfer (CryptoFile file Nothing) fileSize fileInline = do
+ subMode <- chatReadVar subscriptionMode
(agentConnId_, fileConnReq) <-
if isJust fileInline
then pure (Nothing, Nothing)
- else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing)
+ else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode)
let fileName = takeFileName file
fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
chSize <- asks $ fileChunkSize . config
withStore' $ \db -> do
- ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize
+ ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode
fileStatus <- case fileInline of
Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1
_ -> pure CIFSSndStored
@@ -584,36 +590,38 @@ processChatCommand = \case
pure (fileInvitation, ciFile, ft)
prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> m (MsgContainer, Maybe (CIQuote 'CTDirect))
prepareMsg fInv_ timed_ = case quotedItemId_ of
- Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing)
+ Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing)
Just quotedItemId -> do
CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <-
withStore $ \db -> getDirectChatItem db user chatId quotedItemId
(origQmc, qd, sent) <- quoteData qci
- let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing}
+ let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing, msgScope = Nothing}
qmc = quoteContent origQmc file
quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText}
- pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem)
+ pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Just quotedItem)
where
quoteData :: ChatItem c d -> m (MsgContent, CIQDirection 'CTDirect, Bool)
quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote
quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True)
quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False)
quoteData _ = throwChatError CEInvalidQuote
- CTGroup -> do
- g@(Group gInfo _) <- withStore $ \db -> getGroup db user chatId
- assertUserGroupRole gInfo GRAuthor
- send g
+ SRGroup chatId directMemberId -> do
+ gInfo <- withStore $ \db -> getGroupInfo db user chatId
+ directMember <- forM directMemberId $ \dmId -> withStore $ \db -> getGroupMember db user chatId dmId
+ assertGroupSendAllowed gInfo directMember
+ send gInfo directMember
where
- send g@(Group gInfo@GroupInfo {groupId, membership, localDisplayName = gName} ms)
+ send gInfo@GroupInfo {groupId, membership, localDisplayName = gName} directMember
| isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice
| not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles
| otherwise = do
- (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms)
+ ms <- getReceivingMembers user gInfo directMember
+ (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo ms (length ms)
timed_ <- sndGroupCITimed live gInfo itemTTL
- (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership
+ (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership directMember
(msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer)
mapM_ (sendGroupFileInline ms sharedMsgId) ft_
- ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live
+ ci <- saveSndChatItem' user (CDGroupSnd gInfo directMember) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live
withStore' $ \db ->
forM_ sentToMembers $ \GroupMember {groupMemberId} ->
createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew
@@ -622,12 +630,12 @@ processChatCommand = \case
setActive $ ActiveG gName
pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f))
- setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta))
- setupSndFileTransfer g@(Group gInfo _) n = forM file_ $ \file -> do
+ setupSndFileTransfer :: GroupInfo -> [GroupMember] -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta))
+ setupSndFileTransfer gInfo ms n = forM file_ $ \file -> do
(fileSize, fileMode) <- checkSndFile mc file $ fromIntegral n
case fileMode of
SendFileSMP fileInline -> smpSndFileTransfer file fileSize fileInline
- SendFileXFTP -> xftpSndFileTransfer user file fileSize n $ CGGroup g
+ SendFileXFTP -> xftpSndFileTransfer user file fileSize n $ SDGroup gInfo ms
where
smpSndFileTransfer :: CryptoFile -> Integer -> Maybe InlineFileMode -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
smpSndFileTransfer (CryptoFile _ (Just _)) _ _ = throwChatError $ CEFileInternal "locally encrypted files can't be sent via SMP" -- can only happen if XFTP is disabled
@@ -651,25 +659,44 @@ processChatCommand = \case
void . withStore' $ \db -> createSndGroupInlineFT db m conn ft
sendMemberFileInline m conn ft sharedMsgId
processMember _ = pure ()
- prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup))
- prepareMsg fInv_ timed_ membership = case quotedItemId_ of
- Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing)
+ prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> GroupMember -> Maybe GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup))
+ prepareMsg fInv_ timed_ membership directMember = case quotedItemId_ of
+ Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live) (Just msgScope)), Nothing)
Just quotedItemId -> do
- CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <-
+ CChatItem _ qci@ChatItem {chatDir = quoteChatDir, meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <-
withStore $ \db -> getGroupChatItem db user chatId quotedItemId
- (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership
- let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId}
+ let qMsgScope = directMemberToMsgScope $ ciDirDirectMember quoteChatDir
+ (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership qMsgScope
+ let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId, msgScope = Just qMsgScope}
qmc = quoteContent origQmc file
- quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText}
- pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem)
+ quotedItem = CIQuote {chatDir = qd, itemId = Just $ chatItemId' qci, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText}
+ pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live) (Just msgScope)), Just quotedItem)
where
- quoteData :: ChatItem c d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember)
- quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote
- quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership')
- quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m)
- quoteData _ _ = throwChatError CEInvalidQuote
- CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
- CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
+ msgScope = directMemberToMsgScope directMember
+ quoteData :: ChatItem c d -> GroupMember -> MessageScope -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember)
+ quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ _ =
+ throwChatError CEInvalidQuote
+ quoteData ChatItem {chatDir = qChatDir@(CIGroupSnd _), content = CISndMsgContent qmc} membership' qMsgScope =
+ checkDirDirectMember qChatDir $ pure (qmc, CIQGroupSnd qMsgScope, True, membership')
+ quoteData ChatItem {chatDir = qChatDir@(CIGroupRcv m _), content = CIRcvMsgContent qmc} _ qMsgScope =
+ checkDirDirectMember qChatDir $ pure (qmc, CIQGroupRcv (Just m) qMsgScope, False, m)
+ quoteData _ _ _ =
+ throwChatError CEInvalidQuote
+ -- can quote:
+ -- - group message to group (Nothing, Nothing)
+ -- - group message to direct member (Nothing, Just)
+ -- - direct message to the same direct member (Just, Just, same Id)
+ -- can't quote:
+ -- - direct message to group (Just, Nothing)
+ -- - direct message to another direct member (Just, Just, different Id)
+ checkDirDirectMember :: CIDirection 'CTGroup d -> m a -> m a
+ checkDirDirectMember quoteChatDir a = case (ciDirDirectMember quoteChatDir, directMember) of
+ (Nothing, Nothing) -> a
+ (Nothing, Just _) -> a
+ (Just _, Nothing) -> throwChatError CEInvalidQuote
+ (Just GroupMember {groupMemberId = dirDirectMemId}, Just GroupMember {groupMemberId = directMemId})
+ | directMemId == dirDirectMemId -> a
+ | otherwise -> throwChatError CEInvalidQuote
where
quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent
quoteContent qmc ciFile_
@@ -694,8 +721,8 @@ processChatCommand = \case
qText = msgContentText qmc
qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_
qTextOrFile = if T.null qText then qFileName else qText
- xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
- xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do
+ xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> SendDirection -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
+ xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n sendDirection = do
let fileName = takeFileName filePath
fileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False}
fInv = xftpFileInvitation fileName fileSize fileDescr
@@ -704,19 +731,20 @@ processChatCommand = \case
aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n)
-- TODO CRSndFileStart event for XFTP
chSize <- asks $ fileChunkSize . config
+ let contactOrGroup = sendDirToContactOrGroup sendDirection
ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup file fInv (AgentSndFileId aFileId) chSize
let fileSource = Just $ CryptoFile filePath cfArgs
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP}
- case contactOrGroup of
- CGContact Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr
- CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user))
- where
- -- we are not sending files to pending members, same as with inline files
- saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} =
- when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $
- withStore' $ \db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr
- saveMemberFD _ = pure ()
+ case sendDirection of
+ SDDirect Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr
+ SDGroup _ ms -> forM_ ms $ \m -> saveMemberFD ft fileDescr m `catchChatError` (toView . CRChatError (Just user))
pure (fInv, ciFile, ft)
+ where
+ -- we are not sending files to pending members, same as with inline files
+ saveMemberFD ft fileDescr m@GroupMember {activeConn = Just conn@Connection {connStatus}} =
+ when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $
+ withStore' $ \db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr
+ saveMemberFD _ _ _ = pure ()
unzipMaybe3 :: Maybe (a, b, c) -> (Maybe a, Maybe b, Maybe c)
unzipMaybe3 (Just (a, b, c)) = (Just a, Just b, Just c)
unzipMaybe3 _ = (Nothing, Nothing, Nothing)
@@ -744,28 +772,28 @@ processChatCommand = \case
_ -> throwChatError CEInvalidChatItemUpdate
CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
CTGroup -> do
- Group gInfo@GroupInfo {groupId, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId
- assertUserGroupRole gInfo GRAuthor
- cci <- withStore $ \db -> getGroupChatItem db user chatId itemId
- case cci of
- CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do
- case (ciContent, itemSharedMsgId, editable) of
- (CISndMsgContent oldMC, Just itemSharedMId, True) -> do
- let changed = mc /= oldMC
- if changed || fromMaybe False itemLive
- then do
- (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
- ci' <- withStore' $ \db -> do
- currentTs <- liftIO getCurrentTime
- when changed $
- addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
- updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId
- startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
- setActive $ ActiveG gName
- pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci')
- else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
- _ -> throwChatError CEInvalidChatItemUpdate
- CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
+ aci <- withStore $ \db -> getAChatItem db user (ChatRef CTGroup chatId) itemId
+ case aci of
+ AChatItem _ _ (GroupChat gInfo) ci@ChatItem {chatDir = CIGroupSnd directMember, meta = CIMeta {itemSharedMsgId = Just sharedMsgId, editable = True}, content = CISndMsgContent oldMC} -> do
+ let GroupInfo {groupId, localDisplayName = gName} = gInfo
+ ChatItem {meta = CIMeta {itemTimed, itemLive}} = ci
+ changed = mc /= oldMC
+ if changed || fromMaybe False itemLive
+ then do
+ (ms, directMember') <- getReceivingMembers' user gInfo directMember
+ assertGroupSendAllowed gInfo directMember'
+ let msg = XMsgUpdate sharedMsgId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)
+ (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms msg
+ ci' <- withStore' $ \db -> do
+ currentTs <- liftIO getCurrentTime
+ when changed $
+ addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
+ updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId
+ startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
+ setActive $ ActiveG gName
+ pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci')
+ else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
+ _ -> throwChatError CEInvalidChatItemUpdate
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of
@@ -782,22 +810,25 @@ processChatCommand = \case
else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime
(CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete
CTGroup -> do
- Group gInfo ms <- withStore $ \db -> getGroup db user chatId
- ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> getGroupChatItem db user chatId itemId
- case (mode, msgDir, itemSharedMsgId, editable) of
- (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime
- (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do
- assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier
- (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId Nothing
- delGroupChatItem user gInfo ci msgId Nothing
- (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete
+ aci <- withStore $ \db -> getAChatItem db user (ChatRef CTGroup chatId) itemId
+ case (mode, aci) of
+ (CIDMInternal, AChatItem _ md (GroupChat gInfo) ci) -> do
+ let cci = CChatItem md ci
+ deleteGroupCI user gInfo cci True False Nothing =<< liftIO getCurrentTime
+ (CIDMBroadcast, AChatItem _ _ (GroupChat gInfo) ci@ChatItem {chatDir = CIGroupSnd directMember, meta = CIMeta {itemSharedMsgId = Just sharedMsgId, editable = True}}) -> do
+ assertUserMembershipStatus gInfo -- can delete messages sent earlier in any role
+ (ms, _) <- getReceivingMembers' user gInfo directMember
+ (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel sharedMsgId Nothing
+ let cci = CChatItem SMDSnd ci
+ delGroupChatItem user gInfo cci msgId Nothing
+ _ -> throwChatError CEInvalidChatItemDelete
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withChatLock "deleteChatItem" $ do
Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db user gId
ci@(CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) <- withStore $ \db -> getGroupChatItem db user gId itemId
case (chatDir, itemSharedMsgId) of
- (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do
+ (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId} MSGroup, Just itemSharedMId) -> do
when (groupMemberId /= mId) $ throwChatError CEInvalidChatItemDelete
assertUserGroupRole gInfo $ max GRAdmin memberRole
(SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId
@@ -823,13 +854,15 @@ processChatCommand = \case
pure $ CRChatItemReaction user add r
_ -> throwChatError $ CECommandError "reaction not possible - no shared item ID"
CTGroup ->
- withStore (\db -> (,) <$> getGroup db user chatId <*> getGroupChatItem db user chatId itemId) >>= \case
- (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do
+ withStore (\db -> getAChatItem db user (ChatRef CTGroup chatId) itemId) >>= \case
+ (AChatItem _ md (GroupChat g@GroupInfo {membership}) ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do
unless (groupFeatureAllowed SGFReactions g) $
throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)
unless (ciReactionAllowed ci) $
throwChatError $ CECommandError "reaction not allowed - chat item has no content"
let GroupMember {memberId = itemMemberId} = chatItemMember g ci
+ directMember = ciDirDirectMember chatDir
+ (ms, _) <- getReceivingMembers' user g directMember
rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True
checkReactionAllowed rs
(SndMessage {msgId}, _) <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add)
@@ -838,7 +871,7 @@ processChatCommand = \case
setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt
liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId
let ci' = CChatItem md ci {reactions}
- r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction
+ r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction (CIGroupSnd directMember) ci' createdAt reaction
pure $ CRChatItemReaction user add r
_ -> throwChatError $ CECommandError "reaction not possible - no shared item ID"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
@@ -1202,7 +1235,7 @@ processChatCommand = \case
case memberConnId m of
Just connId -> do
cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force
- createInternalChatItem user (CDGroupSnd g) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing
+ createInternalChatItem user (CDGroupSnd g Nothing) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing
pure $ CRGroupMemberRatchetSyncStarted user g m cStats
_ -> throwChatError CEGroupMemberNotActive
APIGetContactCode contactId -> withUser $ \user -> do
@@ -1273,8 +1306,9 @@ processChatCommand = \case
APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do
-- [incognito] generate profile for connection
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
- (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing
- conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile
+ subMode <- chatReadVar subscriptionMode
+ (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode
+ conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode
toView $ CRNewContactConnection user conn
pure $ CRInvitation user cReq conn
AddContact incognito -> withUser $ \User {userId} ->
@@ -1295,12 +1329,13 @@ processChatCommand = \case
Just conn' -> pure $ CRConnectionIncognitoUpdated user conn'
Nothing -> throwChatError CEConnectionIncognitoChangeProhibited
APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do
+ subMode <- chatReadVar subscriptionMode
-- [incognito] generate profile to send
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
dm <- directMessage $ XInfo profileToSend
- connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm
- conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend
+ connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode
+ conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode
toView $ CRNewContactConnection user conn
pure $ CRSentConfirmation user
APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq
@@ -1317,8 +1352,9 @@ processChatCommand = \case
ListContacts -> withUser $ \User {userId} ->
processChatCommand $ APIListContacts userId
APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do
- (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing
- withStore $ \db -> createUserContactLink db user connId cReq
+ subMode <- chatReadVar subscriptionMode
+ (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing subMode
+ withStore $ \db -> createUserContactLink db user connId cReq subMode
pure $ CRUserContactLinkCreated user cReq
CreateMyAddress -> withUser $ \User {userId} ->
processChatCommand $ APICreateMyAddress userId
@@ -1359,8 +1395,8 @@ processChatCommand = \case
RejectContact cName -> withUser $ \User {userId} -> do
connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName
processChatCommand $ APIRejectContact connReqId
- SendMessage chatName msg -> sendTextMessage chatName msg False
- SendLiveMessage chatName msg -> sendTextMessage chatName msg True
+ SendMessage sendName msg -> sendTextMessage sendName msg False
+ SendLiveMessage sendName msg -> sendTextMessage sendName msg True
SendMessageBroadcast msg -> withUser $ \user -> do
contacts <- withStore' (`getUserContacts` user)
let cts = filter (\ct -> isReady ct && directOrUsed ct) contacts
@@ -1380,7 +1416,7 @@ processChatCommand = \case
contactId <- withStore $ \db -> getContactIdByName db user cName
quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg
let mc = MCText msg
- processChatCommand . APISendMessage (ChatRef CTDirect contactId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
+ processChatCommand . APISendMessage (SRDirect contactId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
DeleteMessage chatName deletedMsg -> withUser $ \user -> do
chatRef <- getChatRef user chatName
deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg
@@ -1394,10 +1430,10 @@ processChatCommand = \case
editedItemId <- getSentChatItemIdByText user chatRef editedMsg
let mc = MCText msg
processChatCommand $ APIUpdateChatItem chatRef editedItemId False mc
- UpdateLiveMessage chatName chatItemId live msg -> withUser $ \user -> do
- chatRef <- getChatRef user chatName
+ UpdateLiveMessage sendName chatItemId live msg -> withUser $ \user -> do
+ sendRef <- getSendRef user sendName
let mc = MCText msg
- processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc
+ processChatCommand $ APIUpdateChatItem (sendToChatRef sendRef) chatItemId live mc
ReactToMessage add reaction chatName msg -> withUser $ \user -> do
chatRef <- getChatRef user chatName
chatItemId <- getChatItemIdByText user chatRef msg
@@ -1423,8 +1459,9 @@ processChatCommand = \case
case contactMember contact members of
Nothing -> do
gVar <- asks idsDrg
- (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing
- member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq
+ subMode <- chatReadVar subscriptionMode
+ (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode
+ member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq subMode
sendInvitation member cReq
pure $ CRSentGroupInvitation user gInfo contact member
Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole}
@@ -1443,10 +1480,11 @@ processChatCommand = \case
let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation
Contact {activeConn = Connection {peerChatVRange}} = ct
withChatLock "joinGroup" . procCmd $ do
+ subMode <- chatReadVar subscriptionMode
dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember))
- agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm
+ agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode
withStore' $ \db -> do
- createMemberConnection db userId fromMember agentConnId peerChatVRange
+ createMemberConnection db userId fromMember agentConnId peerChatVRange subMode
updateGroupMemberStatus db userId fromMember GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
updateCIGroupInvitationStatus user
@@ -1480,7 +1518,7 @@ processChatCommand = \case
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
_ -> do
(msg, _) <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole
- ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent)
+ ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndGroupEvent gEvent)
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole}
APIRemoveMember groupId memberId -> withUser $ \user -> do
@@ -1496,7 +1534,7 @@ processChatCommand = \case
withStore' $ \db -> deleteGroupMember db user m
_ -> do
(msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId
- ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile))
+ ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile))
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
deleteMemberConnection user m
-- undeleted "member connected" chat item will prevent deletion of member record
@@ -1506,7 +1544,7 @@ processChatCommand = \case
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId
withChatLock "leaveGroup" . procCmd $ do
(msg, _) <- sendGroupMessage user gInfo members XGrpLeave
- ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
+ ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndGroupEvent SGEUserLeft)
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
-- TODO delete direct connections that were unused
deleteGroupLinkIfExists user gInfo
@@ -1557,9 +1595,10 @@ processChatCommand = \case
assertUserGroupRole gInfo GRAdmin
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
+ subMode <- chatReadVar subscriptionMode
let crClientData = encodeJSON $ CRDataGroup groupLinkId
- (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
- withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
+ (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) subMode
+ withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode
pure $ CRGroupLinkCreated user gInfo cReq mRole
APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do
gInfo <- withStore $ \db -> getGroupInfo db user groupId
@@ -1588,11 +1627,13 @@ processChatCommand = \case
ShowGroupLink gName -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIGetGroupLink groupId
- SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do
+ SendGroupMessageQuote gName cName directMemberName quotedMsg msg -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName
+ directMemberId <- forM directMemberName $ \dmn -> withStore $ \db -> getGroupMemberIdByName db user groupId dmn
quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg
- let mc = MCText msg
- processChatCommand . APISendMessage (ChatRef CTGroup groupId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
+ let sendRef = SRGroup groupId directMemberId
+ mc = MCText msg
+ processChatCommand . APISendMessage sendRef False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
LastChats count_ -> withUser' $ \user -> do
chats <- withStore' $ \db -> getChatPreviews db user False
pure $ CRChats $ maybe id take count_ chats
@@ -1625,19 +1666,19 @@ processChatCommand = \case
processChatCommand $ APIGetChatItemInfo chatRef itemId
ShowLiveItems on -> withUser $ \_ ->
asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_
- SendFile chatName f -> withUser $ \user -> do
- chatRef <- getChatRef user chatName
- processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "")
- SendImage chatName f -> withUser $ \user -> do
- chatRef <- getChatRef user chatName
+ SendFile sendName f -> withUser $ \user -> do
+ sendRef <- getSendRef user sendName
+ processChatCommand . APISendMessage sendRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "")
+ SendImage sendName f -> withUser $ \user -> do
+ sendRef <- getSendRef user sendName
filePath <- toFSFilePath f
unless (any (`isSuffixOf` map toLower f) imageExtensions) $ throwChatError CEFileImageType {filePath}
fileSize <- getFileSize filePath
unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath}
-- TODO include file description for preview
- processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview)
- ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
- ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
+ processChatCommand . APISendMessage sendRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview)
+ ForwardFile sendName fileId -> forwardFile sendName fileId SendFile
+ ForwardImage sendName fileId -> forwardFile sendName fileId SendImage
SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO"
ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ ->
withChatLock "receiveFile" . procCmd $ do
@@ -1661,7 +1702,7 @@ processChatCommand = \case
(_, RcvFileTransfer {xftpRcvFile = f}) <- withStore (`getRcvFileTransferById` fileId)
unless (isJust f) $ throwChatError $ CEFileInternal "locally encrypted files can't be received via SMP"
liftIO $ Just <$> CF.randomArgs
- CancelFile fileId -> withUser $ \user@User {userId} ->
+ CancelFile fileId -> withUser $ \user ->
withChatLock "cancelFile" . procCmd $
withStore (\db -> getFileTransfer db user fileId) >>= \case
FTSnd ftm@FileTransferMeta {xftpSndFile, cancelled} fts
@@ -1671,20 +1712,22 @@ processChatCommand = \case
| otherwise -> do
fileAgentConnIds <- cancelSndFile user ftm fts True
deleteAgentConnectionsAsync user fileAgentConnIds
- sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
- withStore (\db -> getChatRefByFileId db user fileId) >>= \case
- ChatRef CTDirect contactId -> do
- contact <- withStore $ \db -> getContact db user contactId
- void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
- ChatRef CTGroup groupId -> do
- Group gInfo ms <- withStore $ \db -> getGroup db user groupId
- void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId
- _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
- ci <- withStore $ \db -> getChatItemByFileId db user fileId
- pure $ CRSndFileCancelled user ci ftm fts
+ sendXFileCancel
+ ci' <- withStore $ \db -> getChatItemByFileId db user fileId
+ pure $ CRSndFileCancelled user ci' ftm fts
where
fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} =
s == FSCancelled || (s == FSComplete && isNothing xftpSndFile)
+ sendXFileCancel :: m ()
+ sendXFileCancel = do
+ ci <- withStore $ \db -> getChatItemByFileId db user fileId
+ case ci of
+ (AChatItem _ _ (DirectChat ct) ChatItem {chatDir = CIDirectSnd, meta = CIMeta {itemSharedMsgId = Just sharedMsgId}}) ->
+ void $ sendDirectContactMessage ct (XFileCancel sharedMsgId)
+ (AChatItem _ _ (GroupChat gInfo) ChatItem {chatDir = CIGroupSnd directMember, meta = CIMeta {itemSharedMsgId = Just sharedMsgId}}) -> do
+ (ms, _) <- getReceivingMembers' user gInfo directMember
+ void $ sendGroupMessage user gInfo ms (XFileCancel sharedMsgId)
+ _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
FTRcv ftr@RcvFileTransfer {cancelled, fileStatus, xftpRcvFile}
| cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled"
| rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete"
@@ -1799,6 +1842,15 @@ processChatCommand = \case
CTDirect -> withStore $ \db -> getContactIdByName db user name
CTGroup -> withStore $ \db -> getGroupIdByName db user name
_ -> throwChatError $ CECommandError "not supported"
+ getSendRef :: User -> SendName -> m SendRef
+ getSendRef user sendName = case sendName of
+ SNDirect name -> SRDirect <$> withStore (\db -> getContactIdByName db user name)
+ SNGroup name directMemberName -> do
+ (gId, dmId) <- withStore $ \db -> do
+ gId <- getGroupIdByName db user name
+ dmId <- forM directMemberName $ \dmn -> getGroupMemberIdByName db user gId dmn
+ pure (gId, dmId)
+ pure $ SRGroup gId dmId
checkChatStopped :: m ChatResponse -> m ChatResponse
checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped)
setStoreChanged :: m ()
@@ -1845,13 +1897,14 @@ processChatCommand = \case
(_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> drgRandomBytes 16
xContactId <- maybe randomXContactId pure xContactId_
+ subMode <- chatReadVar subscriptionMode
-- [incognito] generate profile to send
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
dm <- directMessage (XContact profileToSend $ Just xContactId)
- connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm
+ connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode
let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli
- conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId
+ conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode
toView $ CRNewContactConnection user conn
pure $ CRSentInvitation user incognitoProfile
contactMember :: Contact -> [GroupMember] -> Maybe GroupMember
@@ -1930,15 +1983,29 @@ processChatCommand = \case
assertUserGroupRole g GROwner
g' <- withStore $ \db -> updateGroupProfile db user g p'
(msg, _) <- sendGroupMessage user g' ms (XGrpInfo p')
- let cd = CDGroupSnd g'
+ let cd = CDGroupSnd g' Nothing
unless (sameGroupProfileInfo p p') $ do
ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p')
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci)
createGroupFeatureChangedItems user cd CISndGroupFeature g g'
pure $ CRGroupUpdated user g g' Nothing
+ assertGroupSendAllowed :: GroupInfo -> Maybe GroupMember -> m ()
+ assertGroupSendAllowed
+ gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}}
+ (Just GroupMember {memberRole = directMemberRole, activeConn = Just Connection {peerChatVRange}}) = do
+ unless (isCompatibleRange peerChatVRange groupPrivateMessagesVRange) $ throwChatError CEPeerChatVRangeIncompatible
+ if
+ | userRole >= GRAdmin || directMemberRole >= GRAdmin -> assertUserMembershipStatus gInfo
+ | not (groupFeatureAllowed SGFDirectMessages gInfo) -> throwChatError $ CECommandError "direct messages not allowed"
+ | otherwise -> assertUserGroupRole gInfo GRAuthor
+ assertGroupSendAllowed _ (Just GroupMember {activeConn = Nothing}) = throwChatError CEGroupMemberNotActive
+ assertGroupSendAllowed gInfo Nothing = assertUserGroupRole gInfo GRAuthor
assertUserGroupRole :: GroupInfo -> GroupMemberRole -> m ()
assertUserGroupRole g@GroupInfo {membership} requiredRole = do
when (memberRole (membership :: GroupMember) < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole
+ assertUserMembershipStatus g
+ assertUserMembershipStatus :: GroupInfo -> m ()
+ assertUserMembershipStatus g@GroupInfo {membership} = do
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g)
when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved
unless (memberActive membership) $ throwChatError CEGroupMemberNotActive
@@ -1983,14 +2050,14 @@ processChatCommand = \case
withServerProtocol p action = case userProtocol p of
Just Dict -> action
_ -> throwChatError $ CEServerProtocol $ AProtocolType p
- forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse
- forwardFile chatName fileId sendCommand = withUser $ \user -> do
+ forwardFile :: SendName -> FileTransferId -> (SendName -> FilePath -> ChatCommand) -> m ChatResponse
+ forwardFile sendName fileId sendCommand = withUser $ \user -> do
withStore (\db -> getFileTransfer db user fileId) >>= \case
FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath
FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath
_ -> throwChatError CEFileNotReceived {fileId}
where
- forward = processChatCommand . sendCommand chatName
+ forward = processChatCommand . sendCommand sendName
getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId)
getGroupAndMemberId user gName groupMemberName =
withStore $ \db -> do
@@ -2006,10 +2073,10 @@ processChatCommand = \case
ci <- saveSndChatItem user (CDDirectSnd ct) msg content
toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
setActive $ ActiveG localDisplayName
- sendTextMessage chatName msg live = withUser $ \user -> do
- chatRef <- getChatRef user chatName
+ sendTextMessage sendName msg live = withUser $ \user -> do
+ sendRef <- getSendRef user sendName
let mc = MCText msg
- processChatCommand . APISendMessage chatRef live Nothing $ ComposedMessage Nothing Nothing mc
+ processChatCommand . APISendMessage sendRef live Nothing $ ComposedMessage Nothing Nothing mc
sndContactCITimed :: Bool -> Contact -> Maybe Int -> m (Maybe CITimed)
sndContactCITimed live = sndCITimed_ live . contactTimedTTL
sndGroupCITimed :: Bool -> GroupInfo -> Maybe Int -> m (Maybe CITimed)
@@ -2081,6 +2148,26 @@ processChatCommand = \case
_ -> throwChatError $ CECommandError "not supported"
processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings
+getReceivingMembers :: ChatMonad m => User -> GroupInfo -> Maybe GroupMember -> m [GroupMember]
+getReceivingMembers user gInfo directMember = do
+ ms <- case directMember of
+ Nothing -> withStore' $ \db -> getGroupMembers db user gInfo
+ Just dm -> pure [dm]
+ pure $ filter memberCurrent ms
+
+-- use in contexts where directMember is retrieved via chat item direction:
+-- when reading chat item member is loaded w/t connection
+getReceivingMembers' :: ChatMonad m => User -> GroupInfo -> Maybe GroupMember -> m ([GroupMember], Maybe GroupMember)
+getReceivingMembers' user gInfo@GroupInfo {groupId} directMember = do
+ (ms, dm) <- case directMember of
+ Nothing -> do
+ ms <- withStore' $ \db -> getGroupMembers db user gInfo
+ pure (ms, Nothing)
+ Just GroupMember {groupMemberId} -> do
+ dm <- withStore $ \db -> getGroupMember db user groupId groupMemberId
+ pure ([dm], Just dm)
+ pure (filter memberCurrent ms, dm)
+
assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m ()
assertDirectAllowed user dir ct event =
unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $
@@ -2240,9 +2327,11 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI
case (xftpRcvFile, fileConnReq) of
-- direct file protocol
(Nothing, Just connReq) -> do
- connIds <- joinAgentConnectionAsync user True connReq =<< directMessage (XFileAcpt fName)
+ subMode <- chatReadVar subscriptionMode
+ dm <- directMessage $ XFileAcpt fName
+ connIds <- joinAgentConnectionAsync user True connReq dm subMode
filePath <- getRcvFilePath fileId filePath_ fName True
- withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath
+ withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath subMode
-- XFTP
(Just XFTPRcvFile {cryptoArgs}, _) -> do
filePath <- getRcvFilePath fileId filePath_ fName False
@@ -2283,8 +2372,9 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI
| fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName
| otherwise -> do
-- accepting via a new connection
- connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation
- withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath
+ subMode <- chatReadVar subscriptionMode
+ connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode
+ withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath subMode
receiveInline :: m Bool
receiveInline = do
ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config
@@ -2296,7 +2386,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI
)
receiveViaCompleteFD :: ChatMonad m => User -> FileTransferId -> RcvFileDescr -> Maybe CryptoFileArgs -> m ()
-receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} cfArgs =
+receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} cfArgs = do
when fileDescrComplete $ do
rd <- parseFileDescription fileDescrText
aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs
@@ -2356,17 +2446,19 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of
acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact
acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do
+ subMode <- chatReadVar subscriptionMode
let profileToSend = profileToSendOnAccept user incognitoProfile
dm <- directMessage $ XInfo profileToSend
- acId <- withAgent $ \a -> acceptContact a True invId dm
- withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile
+ acId <- withAgent $ \a -> acceptContact a True invId dm subMode
+ withStore' $ \db -> createAcceptedContact db user acId cReqChatVRange cName profileId cp userContactLinkId xContactId incognitoProfile subMode
acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact
acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do
+ subMode <- chatReadVar subscriptionMode
let profileToSend = profileToSendOnAccept user incognitoProfile
- (cmdId, acId) <- agentAcceptContactAsync user True invId $ XInfo profileToSend
+ (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode
withStore' $ \db -> do
- ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile
+ ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode
setCommandConnId db user cmdId connId
pure ct
@@ -2413,18 +2505,28 @@ agentSubscriber = do
type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ()))
-subscribeUserConnections :: forall m. ChatMonad m => AgentBatchSubscribe m -> User -> m ()
-subscribeUserConnections agentBatchSubscribe user@User {userId} = do
+subscribeUserConnections :: forall m. ChatMonad m => Bool -> AgentBatchSubscribe m -> User -> m ()
+subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
-- get user connections
ce <- asks $ subscriptionEvents . config
- (ctConns, cts) <- getContactConns
- (ucConns, ucs) <- getUserContactLinkConns
- (gs, mConns, ms) <- getGroupMemberConns
- (sftConns, sfts) <- getSndFileTransferConns
- (rftConns, rfts) <- getRcvFileTransferConns
- (pcConns, pcs) <- getPendingContactConns
+ (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <-
+ if onlyNeeded
+ then do
+ (conns, entities) <- withStore' getConnectionsToSubscribe
+ let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities
+ pure (conns, cts, ucs, [], ms, sfts, rfts, pcs)
+ else do
+ withStore' unsetConnectionToSubscribe
+ (ctConns, cts) <- getContactConns
+ (ucConns, ucs) <- getUserContactLinkConns
+ (gs, mConns, ms) <- getGroupMemberConns
+ (sftConns, sfts) <- getSndFileTransferConns
+ (rftConns, rfts) <- getRcvFileTransferConns
+ (pcConns, pcs) <- getPendingContactConns
+ let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns]
+ pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs)
-- subscribe using batched commands
- rs <- withAgent (`agentBatchSubscribe` concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns])
+ rs <- withAgent $ \a -> agentBatchSubscribe a conns
-- send connection events to view
contactSubsToView rs cts ce
contactLinkSubsToView rs ucs
@@ -2433,6 +2535,29 @@ subscribeUserConnections agentBatchSubscribe user@User {userId} = do
rcvFileSubsToView rs rfts
pendingConnSubsToView rs pcs
where
+ addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case
+ RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs)
+ RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs')
+ RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs)
+ SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs)
+ RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs)
+ UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs)
+ addConn :: Connection -> a -> Map ConnId a -> Map ConnId a
+ addConn = M.insert . aConnId
+ toPCC Connection {connId, agentConnId, connStatus, viaUserContactLink, groupLinkId, customUserProfileId, localAlias, createdAt} =
+ PendingContactConnection
+ { pccConnId = connId,
+ pccAgentConnId = agentConnId,
+ pccConnStatus = connStatus,
+ viaContactUri = False,
+ viaUserContactLink,
+ groupLinkId,
+ customUserProfileId,
+ connReqInv = Nothing,
+ localAlias,
+ createdAt,
+ updatedAt = createdAt
+ }
getContactConns :: m ([ConnId], Map ConnId Contact)
getContactConns = do
cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts
@@ -2698,45 +2823,46 @@ processAgentMsgSndFile _corrId aFileId msg =
toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal
SFDONE sndDescr rfds -> do
withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr)
- ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <-
- withStore $ \db -> getChatItemByFileId db user fileId
- case (msgId_, itemDeleted) of
- (Just sharedMsgId, Nothing) -> do
+ ci <- withStore $ \db -> getChatItemByFileId db user fileId
+ case (rfds, sfts, ci) of
+ (rfd : extraRFDs, [sft], AChatItem _ _ (DirectChat ct) ChatItem {chatDir = CIDirectSnd, meta = CIMeta {itemSharedMsgId = Just sharedMsgId, itemDeleted = Nothing}}) -> do
+ checkStart ci
+ withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
+ msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct
+ withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
+ withAgent (`xftpDeleteSndFileInternal` aFileId)
+ (_, _, AChatItem _ _ (GroupChat gInfo) ChatItem {chatDir = CIGroupSnd directMember, meta = CIMeta {itemSharedMsgId = Just sharedMsgId, itemDeleted = Nothing}}) -> do
+ checkStart ci
+ (ms, _) <- getReceivingMembers' user gInfo directMember
+ let rfdsMemberFTs = zip rfds $ memberFTs ms
+ extraRFDs = drop (length rfdsMemberFTs) rfds
+ withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
+ forM_ rfdsMemberFTs $ \mt -> sendToMember gInfo mt `catchChatError` (toView . CRChatError (Just user))
+ ci' <- withStore $ \db -> do
+ liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
+ getChatItemByFileId db user fileId
+ withAgent (`xftpDeleteSndFileInternal` aFileId)
+ toView $ CRSndFileCompleteXFTP user ci' ft
+ where
+ memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)]
+ memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts')
+ where
+ mConns' = mapMaybe useMember ms
+ sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts
+ useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}}
+ | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn)
+ | otherwise = Nothing
+ useMember _ = Nothing
+ sendToMember :: GroupInfo -> (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m ()
+ sendToMember GroupInfo {groupId} (rfd, (conn, sft)) =
+ void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId
+ _ -> throwChatError $ CEInternalError "invalid XFTP file transfer"
+ where
+ checkStart :: AChatItem -> m ()
+ checkStart ci = do
when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send"
-- TODO either update database status or move to SFPROG
toView $ CRSndFileProgressXFTP user ci ft 1 1
- case (rfds, sfts, d, cInfo) of
- (rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
- withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
- msgDeliveryId <- sendFileDescription sft rfd sharedMsgId $ sendDirectContactMessage ct
- withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
- withAgent (`xftpDeleteSndFileInternal` aFileId)
- (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do
- ms <- withStore' $ \db -> getGroupMembers db user g
- let rfdsMemberFTs = zip rfds $ memberFTs ms
- extraRFDs = drop (length rfdsMemberFTs) rfds
- withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
- forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user))
- ci' <- withStore $ \db -> do
- liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
- getChatItemByFileId db user fileId
- withAgent (`xftpDeleteSndFileInternal` aFileId)
- toView $ CRSndFileCompleteXFTP user ci' ft
- where
- memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)]
- memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts')
- where
- mConns' = mapMaybe useMember ms
- sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts
- useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}}
- | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) = Just (groupMemberId, conn)
- | otherwise = Nothing
- useMember _ = Nothing
- sendToMember :: (ValidFileDescription 'FRecipient, (Connection, SndFileTransfer)) -> m ()
- sendToMember (rfd, (conn, sft)) =
- void $ sendFileDescription sft rfd sharedMsgId $ \msg' -> sendDirectMessage conn msg' $ GroupId groupId
- _ -> pure ()
- _ -> pure () -- TODO error?
SFERR e
| temporaryAgentError e ->
throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e
@@ -2971,9 +3097,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
forM_ groupId_ $ \groupId -> do
+ subMode <- chatReadVar subscriptionMode
gVar <- asks idsDrg
- groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation
- withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds peerChatVRange
+ groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode
+ withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct gLinkMemRole groupConnIds peerChatVRange subMode
_ -> pure ()
Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) ->
when (maybe False ((== ConnReady) . connStatus) activeConn) $ do
@@ -3071,7 +3198,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile groupLinkId
(_msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv
-- we could link chat item with sent group invitation message (_msg)
- createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
+ createInternalChatItem user (CDGroupRcv gInfo m MSGroup) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type"
CONF confId _ connInfo -> do
@@ -3160,10 +3287,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
let m' = m {activeConn = Just conn'} :: GroupMember
updateChatLock "groupMessage" event
case event of
- XMsgNew mc -> canSend m' $ newGroupContentMessage gInfo m' mc msg msgMeta
- XMsgFileDescr sharedMsgId fileDescr -> canSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr msgMeta
+ XMsgNew mc -> newGroupContentMessage gInfo m' mc msg msgMeta
+ XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo m' sharedMsgId fileDescr msgMeta
XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m' sharedMsgId msgMeta
- XMsgUpdate sharedMsgId mContent ttl live -> canSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg msgMeta ttl live
+ XMsgUpdate sharedMsgId mContent ttl live -> groupMessageUpdate gInfo m' sharedMsgId mContent msg msgMeta ttl live
XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg msgMeta
XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg msgMeta
-- TODO discontinue XFile
@@ -3187,10 +3314,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
fromMaybe (sendRcptsSmallGroups user) sendRcpts
&& hasDeliveryReceipt (toCMEventTag event)
&& currentMemCount <= smallGroupsRcptsMemLimit
- where
- canSend mem a
- | memberRole (mem :: GroupMember) <= GRObserver = messageError "member is not allowed to send messages"
- | otherwise = a
RCVD msgMeta msgRcpt ->
withAckMessage' agentConnId conn msgMeta $
groupMsgReceived gInfo m conn msgMeta msgRcpt
@@ -3201,8 +3324,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
SWITCH qd phase cStats -> do
toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats)
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
- QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing
- QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
+ QDRcv -> createInternalChatItem user (CDGroupSnd gInfo Nothing) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing
+ QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m MSGroup) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
RSYNC rss cryptoErr_ cStats ->
case (rss, connectionCode, cryptoErr_) of
(RSRequired, _, Just cryptoErr) -> processErr cryptoErr
@@ -3212,7 +3335,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
let m' = m {activeConn = Just (conn {connectionCode = Nothing} :: Connection)} :: GroupMember
ratchetSyncEventItem m'
toView $ CRGroupMemberVerificationReset user gInfo m'
- createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent RCEVerificationCodeReset) Nothing
+ createInternalChatItem user (CDGroupRcv gInfo m' MSGroup) (CIRcvConnEvent RCEVerificationCodeReset) Nothing
_ -> ratchetSyncEventItem m
where
processErr cryptoErr = do
@@ -3226,10 +3349,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci)
_ -> do
toView $ CRGroupMemberRatchetSync user gInfo m (RatchetSyncProgress rss cStats)
- createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvDecryptionError mde n) Nothing
+ createInternalChatItem user (CDGroupRcv gInfo m MSGroup) (CIRcvDecryptionError mde n) Nothing
ratchetSyncEventItem m' = do
toView $ CRGroupMemberRatchetSync user gInfo m' (RatchetSyncProgress rss cStats)
- createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent $ RCERatchetSync rss) Nothing
+ createInternalChatItem user (CDGroupRcv gInfo m' MSGroup) (CIRcvConnEvent $ RCERatchetSync rss) Nothing
OK ->
-- [async agent commands] continuation on receiving OK
withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} ->
@@ -3526,11 +3649,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
memberConnectedChatItem :: GroupInfo -> GroupMember -> m ()
memberConnectedChatItem gInfo m =
-- ts should be broker ts but we don't have it for CON
- createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing
+ createInternalChatItem user (CDGroupRcv gInfo m MSGroup) (CIRcvGroupEvent RGEMemberConnected) Nothing
groupDescriptionChatItem :: GroupInfo -> GroupMember -> Text -> m ()
groupDescriptionChatItem gInfo m descr =
- createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing
+ createInternalChatItem user (CDGroupRcv gInfo m MSGroup) (CIRcvMsgContent $ MCText descr) Nothing
notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m ()
notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} ct_ = do
@@ -3568,7 +3691,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
- let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc
+ let ExtMsgContent content fInv_ _ _ _ = mcExtMsgContent mc
-- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete
-- case content of
-- MCText "hello 111" ->
@@ -3580,7 +3703,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False
setActive $ ActiveC c
else do
- let ExtMsgContent _ _ itemTTL live_ = mcExtMsgContent mc
+ let ExtMsgContent _ _ itemTTL live_ _ = mcExtMsgContent mc
timed_ = rcvContactCITimed ct itemTTL
live = fromMaybe False live_
file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct
@@ -3608,9 +3731,16 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
processFDMessage fileId fileDescr
groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> MsgMeta -> m ()
- groupMessageFileDescription GroupInfo {groupId} _m sharedMsgId fileDescr _msgMeta = do
- fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId
- processFDMessage fileId fileDescr
+ groupMessageFileDescription gInfo@GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId fileDescr _msgMeta = do
+ cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId
+ case cci of
+ CChatItem SMDRcv ChatItem {chatDir = CIGroupRcv m' msgScope, file = Just CIFile {fileId}} ->
+ if sameMemberId memberId m'
+ then do
+ assertMemberSendAllowed gInfo m' msgScope directMsgProhibitedErr
+ processFDMessage fileId fileDescr
+ else messageError "x.msg.file.descr: message of another member"
+ _ -> messageError "x.msg.file.descr: group member attempted invalid file send"
processFDMessage :: FileTransferId -> FileDescr -> m ()
processFDMessage fileId fileDescr = do
@@ -3731,13 +3861,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
where
updateChatItemReaction = do
cr_ <- withStore $ \db -> do
- CChatItem md ci <- getGroupMemberCIBySharedMsgId db user groupId itemMemberId sharedMsgId
- if ciReactionAllowed ci
+ CChatItem md ci@ChatItem {chatDir} <- getGroupMemberCIBySharedMsgId db user groupId itemMemberId sharedMsgId
+ if directMemberCIUpdateAllowed ci m && ciReactionAllowed ci
then liftIO $ do
setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs
reactions <- getGroupCIReactions db g itemMemberId sharedMsgId
let ci' = CChatItem md ci {reactions}
- r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction
+ msgScope = directMemberToMsgScope $ ciDirDirectMember chatDir
+ r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m msgScope) ci' brokerTs reaction
pure $ Just $ CRChatItemReaction user add r
else pure Nothing
mapM_ toView cr_
@@ -3745,6 +3876,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool
reactionAllowed add reaction rs = (reaction `elem` rs) /= add && not (add && length rs >= maxMsgReactions)
+ directMemberCIUpdateAllowed :: ChatItem 'CTGroup d -> GroupMember -> Bool
+ directMemberCIUpdateAllowed ChatItem {chatDir} GroupMember {groupMemberId} =
+ case ciDirDirectMember chatDir of
+ Just GroupMember {groupMemberId = directMemberId} -> groupMemberId == directMemberId
+ Nothing -> True
+
catchCINotFound :: m a -> (SharedMsgId -> m a) -> m a
catchCINotFound f handle =
f `catchChatError` \case
@@ -3752,32 +3889,41 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
e -> throwError e
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
- newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta
- | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice
- | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles
- | otherwise = do
- -- TODO integrity message check
- -- check if message moderation event was received ahead of message
- let timed_ = rcvGroupCITimed gInfo itemTTL
- live = fromMaybe False live_
- withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case
- Just ciModeration -> do
- applyModeration timed_ live ciModeration
- withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_
- Nothing -> createItem timed_ live
+ newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
+ assertMemberSendAllowed gInfo m msgScope $ rejected GFDirectMessages >> directMsgProhibitedErr
+ processMessage
where
+ processMessage
+ | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice
+ | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles
+ | otherwise = do
+ -- TODO integrity message check
+ -- check if message moderation event was received ahead of message
+ let timed_ = rcvGroupCITimed gInfo itemTTL
+ live = fromMaybe False live_
+ case msgScope_ of
+ Nothing -> processGroupScopeMsg timed_ live
+ Just MSGroup -> processGroupScopeMsg timed_ live
+ Just MSDirect -> createItem timed_ live
+ processGroupScopeMsg timed_ live =
+ withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case
+ Just ciModeration -> do
+ applyModeration timed_ live ciModeration
+ withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_
+ Nothing -> createItem timed_ live
rejected f = void $ newChatItem (CIRcvGroupFeatureRejected f) Nothing Nothing False
- ExtMsgContent content fInv_ itemTTL live_ = mcExtMsgContent mc
+ ExtMsgContent content fInv_ itemTTL live_ msgScope_ = mcExtMsgContent mc
+ msgScope = fromMaybe MSGroup msgScope_
applyModeration timed_ live CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, createdByMsgId, moderatedAt}
| moderatorRole < GRAdmin || moderatorRole < memberRole =
createItem timed_ live
| groupFeatureAllowed SGFFullDelete gInfo = do
- ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False
+ ci <- saveRcvChatItem' user (CDGroupRcv gInfo m MSGroup) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False
ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo (CChatItem SMDRcv ci) moderator moderatedAt
toView $ CRNewChatItem user ci'
| otherwise = do
file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m
- ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False
+ ci <- saveRcvChatItem' user (CDGroupRcv gInfo m MSGroup) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False
cr <- markGroupCIDeleted user gInfo (CChatItem SMDRcv ci) createdByMsgId False (Just moderator) moderatedAt
toView cr
createItem timed_ live = do
@@ -3789,24 +3935,39 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
setActive $ ActiveG g
newChatItem ciContent ciFile_ timed_ live = do
- ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live
+ ci <- saveRcvChatItem' user (CDGroupRcv gInfo m msgScope) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live
reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_
groupMsgToView gInfo m ci {reactions} msgMeta
pure ci
+ assertMemberSendAllowed :: GroupInfo -> GroupMember -> MessageScope -> m () -> m ()
+ assertMemberSendAllowed
+ gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}}
+ m@GroupMember {memberRole}
+ MSDirect
+ directMessagesProhibitedAction
+ | userRole >= GRAdmin || memberRole >= GRAdmin = assertMemberStatus m
+ | not (groupFeatureAllowed SGFDirectMessages gInfo) = directMessagesProhibitedAction
+ | otherwise = assertMemberGroupRole m GRAuthor
+ assertMemberSendAllowed _ m MSGroup _ =
+ assertMemberGroupRole m GRAuthor
+
+ directMsgProhibitedErr :: m ()
+ directMsgProhibitedErr = throwChatError $ CECommandError "direct messages not allowed"
+
+ assertMemberGroupRole :: GroupMember -> GroupMemberRole -> m ()
+ assertMemberGroupRole member requiredRole = do
+ when (memberRole (member :: GroupMember) < requiredRole) $ messageError "member is not allowed to send messages (member role)"
+ assertMemberStatus member
+
+ assertMemberStatus :: GroupMember -> m ()
+ assertMemberStatus member =
+ when (memberRemoved member) $ messageError "member is not allowed to send messages (member removed)"
+
groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m ()
- groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ =
- updateRcvChatItem `catchCINotFound` \_ -> do
- -- This patches initial sharedMsgId into chat item when locally deleted chat item
- -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete).
- -- Chat item and update message which created it will have different sharedMsgId in this case...
- let timed_ = rcvGroupCITimed gInfo ttl_
- ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live
- ci' <- withStore' $ \db -> do
- createChatItemVersion db (chatItemId' ci) brokerTs mc
- updateGroupChatItem db user groupId ci content live Nothing
- toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci')
- setActive $ ActiveG g
+ groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} GroupMember {groupMemberId, memberId} sharedMsgId mc RcvMessage {msgId} msgMeta _ttl_ live_ =
+ updateRcvChatItem `catchCINotFound` \_ ->
+ withStore' (`deleteMessage` msgId)
where
MsgMeta {broker = (_, brokerTs)} = msgMeta
content = CIRcvMsgContent mc
@@ -3814,9 +3975,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
updateRcvChatItem = do
cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId
case cci of
- CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} ->
+ CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m' msgScope, meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} ->
if sameMemberId memberId m'
then do
+ assertMemberSendAllowed gInfo m' msgScope directMsgProhibitedErr
let changed = mc /= oldMC
if changed || fromMaybe False itemLive
then do
@@ -3836,10 +3998,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
let msgMemberId = fromMaybe memberId sndMemberId_
withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case
Right ci@(CChatItem _ ChatItem {chatDir}) -> case chatDir of
- CIGroupRcv mem
+ CIGroupRcv mem msgScope
| sameMemberId memberId mem && msgMemberId == memberId -> delete ci Nothing >>= toView
- | otherwise -> deleteMsg mem ci
- CIGroupSnd -> deleteMsg membership ci
+ | msgScope == MSGroup -> deleteMsg mem ci
+ | otherwise -> messageError "x.msg.del: private message of another member"
+ CIGroupSnd directMember
+ | isNothing directMember -> deleteMsg membership ci
+ | otherwise -> messageError "x.msg.del: private message sent by user"
Left e
| msgMemberId == memberId -> messageError $ "x.msg.del: message not found, " <> tshow e
| senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e
@@ -3882,7 +4047,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol}
- ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
+ ci <- saveRcvChatItem' user (CDGroupRcv gInfo m MSGroup) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
groupMsgToView gInfo m ci msgMeta
let g = groupName' gInfo
whenGroupNtfs user gInfo $ do
@@ -3920,8 +4085,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
then unless cancelled $ case fileConnReq_ of
-- receiving via a separate connection
Just fileConnReq -> do
- connIds <- joinAgentConnectionAsync user True fileConnReq =<< directMessage XOk
- withStore' $ \db -> createSndDirectFTConnection db user fileId connIds
+ subMode <- chatReadVar subscriptionMode
+ dm <- directMessage XOk
+ connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode
+ withStore' $ \db -> createSndDirectFTConnection db user fileId connIds subMode
-- receiving inline
_ -> do
event <- withStore $ \db -> do
@@ -3989,11 +4156,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> MsgMeta -> m ()
xFileCancelGroup g@GroupInfo {groupId} mem@GroupMember {groupMemberId, memberId} sharedMsgId msgMeta = do
- checkIntegrityCreateItem (CDGroupRcv g mem) msgMeta
+ checkIntegrityCreateItem (CDGroupRcv g mem MSGroup) msgMeta
fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId
CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId
case (msgDir, chatDir) of
- (SMDRcv, CIGroupRcv m) -> do
+ (SMDRcv, CIGroupRcv m _) -> do
if sameMemberId memberId m
then do
ft <- withStore (\db -> getRcvFileTransfer db user fileId)
@@ -4006,7 +4173,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m ()
xFileAcptInvGroup g@GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName msgMeta = do
- checkIntegrityCreateItem (CDGroupRcv g m) msgMeta
+ checkIntegrityCreateItem (CDGroupRcv g m MSGroup) msgMeta
fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId
(AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId
assertSMPAcceptNotProhibited ci
@@ -4015,10 +4182,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
if fName == fileName
then unless cancelled $ case (fileConnReq_, activeConn) of
(Just fileConnReq, _) -> do
+ subMode <- chatReadVar subscriptionMode
-- receiving via a separate connection
-- [async agent commands] no continuation needed, but command should be asynchronous for stability
- connIds <- joinAgentConnectionAsync user True fileConnReq =<< directMessage XOk
- withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m
+ dm <- directMessage XOk
+ connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode
+ withStore' $ \db -> createSndGroupFileTransferConnection db user fileId connIds m subMode
(_, Just conn) -> do
-- receiving inline
event <- withStore $ \db -> do
@@ -4035,7 +4204,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
groupMsgToView :: GroupInfo -> GroupMember -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m ()
groupMsgToView gInfo m ci msgMeta = do
- checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta
+ checkIntegrityCreateItem (CDGroupRcv gInfo m MSGroup) msgMeta
toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci)
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
@@ -4049,9 +4218,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId
if sameGroupLinkId groupLinkId groupLinkId'
then do
- connIds <- joinAgentConnectionAsync user True connRequest =<< directMessage (XGrpAcpt memberId)
+ subMode <- chatReadVar subscriptionMode
+ dm <- directMessage $ XGrpAcpt memberId
+ connIds <- joinAgentConnectionAsync user True connRequest dm subMode
withStore' $ \db -> do
- createMemberConnectionAsync db user hostId connIds peerChatVRange
+ createMemberConnectionAsync db user hostId connIds peerChatVRange subMode
updateGroupMemberStatusById db userId hostId GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
@@ -4109,7 +4280,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
forM_ allGroupFeatures $ \(AGF f) -> do
let p = getGroupPreference f fullGroupPreferences
(_, param) = groupFeatureState p
- createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing
+ createInternalChatItem user (CDGroupRcv g m MSGroup) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing
xInfoProbe :: Contact -> Probe -> m ()
xInfoProbe c2 probe =
@@ -4272,7 +4443,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
then messageError "x.grp.mem.new error: member already exists"
else do
newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced
- ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile)
+ ci <- saveRcvChatItem user (CDGroupRcv gInfo m MSGroup) msg msgMeta (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile)
groupMsgToView gInfo m ci msgMeta
toView $ CRJoinedGroupMemberConnecting user gInfo m newMember
@@ -4285,18 +4456,19 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
then messageWarning "x.grp.mem.intro ignored: member already exists"
else do
when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c)
+ subMode <- chatReadVar subscriptionMode
-- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second
- groupConnIds <- createConn
+ groupConnIds <- createConn subMode
directConnIds <- case memberChatVRange of
- Nothing -> Just <$> createConn
+ Nothing -> Just <$> createConn subMode
Just mcvr
- | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> Just <$> createConn -- pure Nothing
- | otherwise -> Just <$> createConn
+ | isCompatibleRange (fromChatVRange mcvr) groupNoDirectVRange -> Just <$> createConn subMode -- pure Nothing
+ | otherwise -> Just <$> createConn subMode
let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing
- void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId
+ void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId subMode
_ -> messageError "x.grp.mem.intro can be only sent by host member"
where
- createConn = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation
+ createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation subMode
sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m ()
sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do
@@ -4330,14 +4502,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced
Just m' -> pure m'
withStore' $ \db -> saveMemberInvitation db toMember introInv
+ subMode <- chatReadVar subscriptionMode
-- [incognito] send membership incognito profile, create direct connection as incognito
dm <- directMessage $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
- groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm
- directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm
+ groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm subMode
+ directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm subMode
let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing
mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange
- withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId
+ withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode
xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> MsgMeta -> m ()
xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg msgMeta
@@ -4354,7 +4527,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
| senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions"
| otherwise = do
withStore' $ \db -> updateGroupMemberRole db user member memRole
- ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent)
+ ci <- saveRcvChatItem user (CDGroupRcv gInfo m MSGroup) msg msgMeta (CIRcvGroupEvent gEvent)
groupMsgToView gInfo m ci msgMeta
toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole}
@@ -4389,7 +4562,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
messageError "x.grp.mem.del with insufficient member permissions"
| otherwise = a
deleteMemberItem gEvent = do
- ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent)
+ ci <- saveRcvChatItem user (CDGroupRcv gInfo m MSGroup) msg msgMeta (CIRcvGroupEvent gEvent)
groupMsgToView gInfo m ci msgMeta
sameMemberId :: MemberId -> GroupMember -> Bool
@@ -4400,7 +4573,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
deleteMemberConnection user m
-- member record is not deleted to allow creation of "member left" chat item
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft
- ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEMemberLeft)
+ ci <- saveRcvChatItem user (CDGroupRcv gInfo m MSGroup) msg msgMeta (CIRcvGroupEvent RGEMemberLeft)
groupMsgToView gInfo m ci msgMeta
toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft}
@@ -4413,7 +4586,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
pure members
-- member records are not deleted to keep history
deleteMembersConnections user ms
- ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted)
+ ci <- saveRcvChatItem user (CDGroupRcv gInfo m MSGroup) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted)
groupMsgToView gInfo m ci msgMeta
toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m
@@ -4423,7 +4596,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
| otherwise = unless (p == p') $ do
g' <- withStore $ \db -> updateGroupProfile db user g p'
toView $ CRGroupUpdated user g g' (Just m)
- let cd = CDGroupRcv g' m
+ let cd = CDGroupRcv g' m MSGroup
unless (sameGroupProfileInfo p p') $ do
ci <- saveRcvChatItem user cd msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p')
groupMsgToView g' m ci msgMeta
@@ -4438,7 +4611,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m ()
groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do
- checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta
+ checkIntegrityCreateItem (CDGroupRcv gInfo m MSGroup) msgMeta
forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do
withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus
updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete
@@ -4838,16 +5011,16 @@ cancelCIFile user file_ =
fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True
deleteAgentConnectionsAsync user fileAgentConnIds
-createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId)
-createAgentConnectionAsync user cmdFunction enableNtfs cMode = do
+createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId)
+createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction
- connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode
+ connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode subMode
pure (cmdId, connId)
-joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> m (CommandId, ConnId)
-joinAgentConnectionAsync user enableNtfs cReqUri cInfo = do
+joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> m (CommandId, ConnId)
+joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do
cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn
- connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo
+ connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo subMode
pure (cmdId, connId)
allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m ()
@@ -4857,11 +5030,11 @@ allowAgentConnectionAsync user conn@Connection {connId} confId msg = do
withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm
withStore' $ \db -> updateConnectionStatus db conn ConnAccepted
-agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> m (CommandId, ConnId)
-agentAcceptContactAsync user enableNtfs invId msg = do
+agentAcceptContactAsync :: (MsgEncodingI e, ChatMonad m) => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> m (CommandId, ConnId)
+agentAcceptContactAsync user enableNtfs invId msg subMode = do
cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact
dm <- directMessage msg
- connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm
+ connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId dm subMode
pure (cmdId, connId)
deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m ()
@@ -5151,7 +5324,7 @@ chatCommandP =
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal),
- "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
+ "/_send " *> (APISendMessage <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal),
@@ -5275,8 +5448,7 @@ chatCommandP =
"/set link role #" *> (GroupLinkMemberRole <$> displayName <*> memberRole),
"/delete link #" *> (DeleteGroupLink <$> displayName),
"/show link #" *> (ShowGroupLink <$> displayName),
- (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP),
- (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP),
+ (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <*> optional (" >@" *> displayName) <*> optional (" @" *> displayName) <* A.space <*> quotedMsg <*> msgTextP),
"/_contacts " *> (APIListContacts <$> A.decimal),
"/contacts" $> ListContacts,
"/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
@@ -5284,8 +5456,8 @@ chatCommandP =
"/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP),
("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
("/connect" <|> "/c") *> (AddContact <$> incognitoP),
- SendMessage <$> chatNameP <* A.space <*> msgTextP,
- "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")),
+ SendMessage <$> sendNameP <* A.space <*> msgTextP,
+ "/live " *> (SendLiveMessage <$> sendNameP <*> (A.space *> msgTextP <|> pure "")),
(">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv),
(">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd),
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> textP),
@@ -5300,10 +5472,10 @@ chatCommandP =
"/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)),
"/show " *> (ShowChatItem . Just <$> A.decimal),
"/item info " *> (ShowChatItemInfo <$> chatNameP <* A.space <*> msgTextP),
- ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath),
- ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath),
- ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
- ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal),
+ ("/file " <|> "/f ") *> (SendFile <$> sendNameP' <* A.space <*> filePath),
+ ("/image " <|> "/img ") *> (SendImage <$> sendNameP' <* A.space <*> filePath),
+ ("/fforward " <|> "/ff ") *> (ForwardFile <$> sendNameP' <* A.space <*> A.decimal),
+ ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> sendNameP' <* A.space <*> A.decimal),
("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath),
("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)),
"/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)),
@@ -5414,6 +5586,13 @@ chatCommandP =
chatNameP = ChatName <$> chatTypeP <*> displayName
chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName
chatRefP = ChatRef <$> chatTypeP <*> A.decimal
+ sendNameP =
+ (A.char '@' $> SNDirect <*> displayName)
+ <|> (A.char '#' $> SNGroup <*> displayName <*> optional (" @" *> displayName))
+ sendNameP' = sendNameP <|> (SNDirect <$> displayName)
+ sendRefP =
+ (A.char '@' $> SRDirect <*> A.decimal)
+ <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional (" @" *> A.decimal))
msgCountP = A.space *> A.decimal <|> pure 10
ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal)
ciTTL =
diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs
index df9c66ceee..50bddbb190 100644
--- a/src/Simplex/Chat/Bot.hs
+++ b/src/Simplex/Chat/Bot.hs
@@ -67,7 +67,7 @@ sendComposedMessage cc = sendComposedMessage' cc . contactId'
sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO ()
sendComposedMessage' cc ctId quotedItemId msgContent = do
let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent}
- sendChatCmd cc (APISendMessage (ChatRef CTDirect ctId) False Nothing cm) >>= \case
+ sendChatCmd cc (APISendMessage (SRDirect ctId) False Nothing cm) >>= \case
CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId
r -> putStrLn $ "unexpected send message response: " <> show r
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index 6380da6478..26e4f43565 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -34,6 +34,7 @@ import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import Data.String
import Data.Text (Text)
+import qualified Data.Text as T
import Data.Time (NominalDiffTime)
import Data.Time.Clock (UTCTime)
import Data.Version (showVersion)
@@ -62,7 +63,7 @@ import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
-import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, UserProtocol, XFTPServerWithAuth)
+import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport (simplexMQVersion)
import Simplex.Messaging.Transport.Client (TransportHost)
@@ -176,6 +177,7 @@ data ChatController = ChatController
outputQ :: TBQueue (Maybe CorrId, ChatResponse),
notifyQ :: TBQueue Notification,
sendNotification :: Notification -> IO (),
+ subscriptionMode :: TVar SubscriptionMode,
chatLock :: Lock,
sndFiles :: TVar (Map Int64 Handle),
rcvFiles :: TVar (Map Int64 Handle),
@@ -240,7 +242,7 @@ data ChatCommand
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
| APIGetChatItemInfo ChatRef ChatItemId
- | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage}
+ | APISendMessage {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage}
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
| APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId
@@ -351,14 +353,14 @@ data ChatCommand
| AddressAutoAccept (Maybe AutoAccept)
| AcceptContact IncognitoEnabled ContactName
| RejectContact ContactName
- | SendMessage ChatName Text
- | SendLiveMessage ChatName Text
+ | SendMessage SendName Text
+ | SendLiveMessage SendName Text
| SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: Text, message :: Text}
| SendMessageBroadcast Text -- UserId (not used in UI)
| DeleteMessage ChatName Text
| DeleteMemberMessage GroupName ContactName Text
| EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text}
- | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text}
+ | UpdateLiveMessage {sendName :: SendName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text}
| ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text}
| APINewGroup UserId GroupProfile
| NewGroup GroupProfile
@@ -380,17 +382,17 @@ data ChatCommand
| GroupLinkMemberRole GroupName GroupMemberRole
| DeleteGroupLink GroupName
| ShowGroupLink GroupName
- | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text}
+ | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, directMemberName :: Maybe ContactName, quotedMsg :: Text, message :: Text}
| LastChats (Maybe Int) -- UserId (not used in UI)
| LastMessages (Maybe ChatName) Int (Maybe String) -- UserId (not used in UI)
| LastChatItemId (Maybe ChatName) Int -- UserId (not used in UI)
| ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI)
| ShowChatItemInfo ChatName Text
| ShowLiveItems Bool
- | SendFile ChatName FilePath
- | SendImage ChatName FilePath
- | ForwardFile ChatName FileTransferId
- | ForwardImage ChatName FileTransferId
+ | SendFile SendName FilePath
+ | SendImage SendName FilePath
+ | ForwardFile SendName FileTransferId
+ | ForwardImage SendName FileTransferId
| SendFileDescription ChatName FilePath
| ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath}
| SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool}
@@ -611,6 +613,37 @@ instance ToJSON ChatResponse where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
+data SendRef
+ = SRDirect ContactId
+ | SRGroup GroupId (Maybe GroupMemberId)
+ deriving (Eq, Show)
+
+sendToChatRef :: SendRef -> ChatRef
+sendToChatRef = \case
+ SRDirect cId -> ChatRef CTDirect cId
+ SRGroup gId _ -> ChatRef CTGroup gId
+
+data SendName
+ = SNDirect ContactName
+ | SNGroup GroupName (Maybe ContactName)
+ deriving (Eq, Show)
+
+sendNameStr :: SendName -> String
+sendNameStr = \case
+ SNDirect cName -> "@" <> T.unpack cName
+ SNGroup gName (Just cName) -> "#" <> T.unpack gName <> " @" <> T.unpack cName
+ SNGroup gName Nothing -> "#" <> T.unpack gName
+
+data SendDirection
+ = SDDirect Contact
+ | SDGroup GroupInfo [GroupMember]
+ deriving (Eq, Show)
+
+sendDirToContactOrGroup :: SendDirection -> ContactOrGroup
+sendDirToContactOrGroup = \case
+ SDDirect c -> CGContact c
+ SDGroup g _ -> CGGroup g
+
newtype UserPwd = UserPwd {unUserPwd :: Text}
deriving (Eq, Show)
@@ -926,6 +959,7 @@ data ChatErrorType
| CEAgentCommandError {message :: String}
| CEInvalidFileDescription {message :: String}
| CEConnectionIncognitoChangeProhibited
+ | CEPeerChatVRangeIncompatible
| CEInternalError {message :: String}
| CEException {message :: String}
deriving (Show, Exception, Generic)
@@ -960,6 +994,14 @@ type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m)
type ChatMonad m = (ChatMonad' m, MonadError ChatError m)
+chatReadVar :: ChatMonad' m => (ChatController -> TVar a) -> m a
+chatReadVar f = asks f >>= readTVarIO
+{-# INLINE chatReadVar #-}
+
+chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m ()
+chatWriteVar f value = asks f >>= atomically . (`writeTVar` value)
+{-# INLINE chatWriteVar #-}
+
tryChatError :: ChatMonad m => m a -> m (Either ChatError a)
tryChatError = tryAllErrors mkChatError
{-# INLINE tryChatError #-}
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index 45e5f9ff74..f97a0dd1b3 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -50,16 +50,6 @@ data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection
data ChatName = ChatName ChatType Text
deriving (Show)
-chatTypeStr :: ChatType -> String
-chatTypeStr = \case
- CTDirect -> "@"
- CTGroup -> "#"
- CTContactRequest -> "<@"
- CTContactConnection -> ":"
-
-chatNameStr :: ChatName -> String
-chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name
-
data ChatRef = ChatRef ChatType Int64
deriving (Eq, Show, Ord)
@@ -148,16 +138,16 @@ instance MsgDirectionI d => ToJSON (ChatItem c d) where
data CIDirection (c :: ChatType) (d :: MsgDirection) where
CIDirectSnd :: CIDirection 'CTDirect 'MDSnd
CIDirectRcv :: CIDirection 'CTDirect 'MDRcv
- CIGroupSnd :: CIDirection 'CTGroup 'MDSnd
- CIGroupRcv :: GroupMember -> CIDirection 'CTGroup 'MDRcv
+ CIGroupSnd :: Maybe GroupMember -> CIDirection 'CTGroup 'MDSnd
+ CIGroupRcv :: GroupMember -> MessageScope -> CIDirection 'CTGroup 'MDRcv
deriving instance Show (CIDirection c d)
data JSONCIDirection
= JCIDirectSnd
| JCIDirectRcv
- | JCIGroupSnd
- | JCIGroupRcv {groupMember :: GroupMember}
+ | JCIGroupSnd {directMember :: Maybe GroupMember}
+ | JCIGroupRcv {groupMember :: GroupMember, messageScope :: MessageScope}
deriving (Generic, Show)
instance ToJSON JSONCIDirection where
@@ -172,8 +162,19 @@ jsonCIDirection :: CIDirection c d -> JSONCIDirection
jsonCIDirection = \case
CIDirectSnd -> JCIDirectSnd
CIDirectRcv -> JCIDirectRcv
- CIGroupSnd -> JCIGroupSnd
- CIGroupRcv m -> JCIGroupRcv m
+ CIGroupSnd dm -> JCIGroupSnd dm
+ CIGroupRcv m ms -> JCIGroupRcv m ms
+
+ciDirDirectMember :: CIDirection 'CTGroup d -> Maybe GroupMember
+ciDirDirectMember = \case
+ CIGroupSnd dm -> dm
+ CIGroupRcv _ MSGroup -> Nothing
+ CIGroupRcv m MSDirect -> Just m
+
+directMemberToMsgScope :: Maybe GroupMember -> MessageScope
+directMemberToMsgScope = \case
+ Nothing -> MSGroup
+ Just _ -> MSDirect
data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int}
deriving (Show, Generic)
@@ -208,8 +209,8 @@ timedDeleteAt' CITimed {deleteAt} = deleteAt
chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> GroupMember
chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of
- CIGroupSnd -> membership
- CIGroupRcv m -> m
+ CIGroupSnd _ -> membership
+ CIGroupRcv m _ -> m
ciReactionAllowed :: ChatItem c d -> Bool
ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False
@@ -238,22 +239,22 @@ chatItemDeletedState ChatItem {meta = CIMeta {itemDeleted}, content} =
data ChatDirection (c :: ChatType) (d :: MsgDirection) where
CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd
CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv
- CDGroupSnd :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd
- CDGroupRcv :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv
+ CDGroupSnd :: GroupInfo -> Maybe GroupMember -> ChatDirection 'CTGroup 'MDSnd
+ CDGroupRcv :: GroupInfo -> GroupMember -> MessageScope -> ChatDirection 'CTGroup 'MDRcv
toCIDirection :: ChatDirection c d -> CIDirection c d
toCIDirection = \case
CDDirectSnd _ -> CIDirectSnd
CDDirectRcv _ -> CIDirectRcv
- CDGroupSnd _ -> CIGroupSnd
- CDGroupRcv _ m -> CIGroupRcv m
+ CDGroupSnd _ dm -> CIGroupSnd dm
+ CDGroupRcv _ m ms -> CIGroupRcv m ms
toChatInfo :: ChatDirection c d -> ChatInfo c
toChatInfo = \case
CDDirectSnd c -> DirectChat c
CDDirectRcv c -> DirectChat c
- CDGroupSnd g -> GroupChat g
- CDGroupRcv g _ -> GroupChat g
+ CDGroupSnd g _ -> GroupChat g
+ CDGroupRcv g _ _ -> GroupChat g
data NewChatItem d = NewChatItem
{ createdByMsgId :: Maybe MessageId,
@@ -433,29 +434,39 @@ instance ToJSON (JSONCIReaction c d) where
data CIQDirection (c :: ChatType) where
CIQDirectSnd :: CIQDirection 'CTDirect
CIQDirectRcv :: CIQDirection 'CTDirect
- CIQGroupSnd :: CIQDirection 'CTGroup
- CIQGroupRcv :: Maybe GroupMember -> CIQDirection 'CTGroup -- member can be Nothing in case MsgRef has memberId that the user is not notified about yet
+ CIQGroupSnd :: MessageScope -> CIQDirection 'CTGroup
+ CIQGroupRcv :: Maybe GroupMember -> MessageScope -> CIQDirection 'CTGroup -- member can be Nothing in case MsgRef has memberId that the user is not notified about yet
deriving instance Show (CIQDirection c)
+data JSONCIQDirection
+ = JCIQDirectSnd
+ | JCIQDirectRcv
+ | JCIQGroupSnd {messageScope :: MessageScope}
+ | JCIQGroupRcv {groupMember :: Maybe GroupMember, messageScope :: MessageScope}
+ deriving (Generic, Show)
+
+instance ToJSON JSONCIQDirection where
+ toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIQ"
+ toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIQ"
+
instance ToJSON (CIQDirection c) where
toJSON = J.toJSON . jsonCIQDirection
toEncoding = J.toEncoding . jsonCIQDirection
-jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection
+jsonCIQDirection :: CIQDirection c -> JSONCIQDirection
jsonCIQDirection = \case
- CIQDirectSnd -> Just JCIDirectSnd
- CIQDirectRcv -> Just JCIDirectRcv
- CIQGroupSnd -> Just JCIGroupSnd
- CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m
- CIQGroupRcv Nothing -> Nothing
+ CIQDirectSnd -> JCIQDirectSnd
+ CIQDirectRcv -> JCIQDirectRcv
+ CIQGroupSnd ms -> JCIQGroupSnd ms
+ CIQGroupRcv m ms -> JCIQGroupRcv m ms
quoteMsgDirection :: CIQDirection c -> MsgDirection
quoteMsgDirection = \case
CIQDirectSnd -> MDSnd
CIQDirectRcv -> MDRcv
- CIQGroupSnd -> MDSnd
- CIQGroupRcv _ -> MDRcv
+ CIQGroupSnd _ -> MDSnd
+ CIQGroupRcv _ _ -> MDRcv
data CIFile (d :: MsgDirection) = CIFile
{ fileId :: Int64,
diff --git a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs b/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs
new file mode 100644
index 0000000000..48ad8dbf86
--- /dev/null
+++ b/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs
@@ -0,0 +1,20 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Migrations.M20230903_connections_to_subscribe where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20230903_connections_to_subscribe :: Query
+m20230903_connections_to_subscribe =
+ [sql|
+ALTER TABLE connections ADD COLUMN to_subscribe INTEGER DEFAULT 0 NOT NULL;
+CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe);
+|]
+
+down_m20230903_connections_to_subscribe :: Query
+down_m20230903_connections_to_subscribe =
+ [sql|
+DROP INDEX idx_connections_to_subscribe;
+ALTER TABLE connections DROP COLUMN to_subscribe;
+|]
diff --git a/src/Simplex/Chat/Migrations/M20230904_item_direct_group_member_id.hs b/src/Simplex/Chat/Migrations/M20230904_item_direct_group_member_id.hs
new file mode 100644
index 0000000000..6c150b19ce
--- /dev/null
+++ b/src/Simplex/Chat/Migrations/M20230904_item_direct_group_member_id.hs
@@ -0,0 +1,24 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Migrations.M20230904_item_direct_group_member_id where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20230904_item_direct_group_member_id :: Query
+m20230904_item_direct_group_member_id =
+ [sql|
+ALTER TABLE chat_items ADD COLUMN item_direct_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL;
+ALTER TABLE chat_items ADD COLUMN quoted_message_scope TEXT;
+
+CREATE INDEX idx_chat_items_item_direct_group_member_id ON chat_items(item_direct_group_member_id);
+|]
+
+down_m20230904_item_direct_group_member_id :: Query
+down_m20230904_item_direct_group_member_id =
+ [sql|
+DROP INDEX idx_chat_items_item_direct_group_member_id;
+
+ALTER TABLE chat_items DROP COLUMN quoted_message_scope;
+ALTER TABLE chat_items DROP COLUMN item_direct_group_member_id;
+|]
diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql
index f0731b6ef4..f9a9309db5 100644
--- a/src/Simplex/Chat/Migrations/chat_schema.sql
+++ b/src/Simplex/Chat/Migrations/chat_schema.sql
@@ -287,6 +287,7 @@ CREATE TABLE connections(
auth_err_counter INTEGER DEFAULT 0 CHECK(auth_err_counter NOT NULL),
peer_chat_min_version INTEGER NOT NULL DEFAULT 1,
peer_chat_max_version INTEGER NOT NULL DEFAULT 1,
+ to_subscribe INTEGER DEFAULT 0 NOT NULL,
FOREIGN KEY(snd_file_id, connection_id)
REFERENCES snd_files(file_id, connection_id)
ON DELETE CASCADE
@@ -391,7 +392,9 @@ CREATE TABLE chat_items(
timed_delete_at TEXT,
item_live INTEGER,
item_deleted_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL,
- item_deleted_ts TEXT
+ item_deleted_ts TEXT,
+ item_direct_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL,
+ quoted_message_scope TEXT
);
CREATE TABLE chat_item_messages(
chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE,
@@ -711,3 +714,7 @@ CREATE INDEX idx_chat_items_user_id_item_status ON chat_items(
user_id,
item_status
);
+CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe);
+CREATE INDEX idx_chat_items_item_direct_group_member_id ON chat_items(
+ item_direct_group_member_id
+);
diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs
index 13692b57cc..b42ede41c8 100644
--- a/src/Simplex/Chat/Protocol.hs
+++ b/src/Simplex/Chat/Protocol.hs
@@ -44,7 +44,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Util
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
-import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON)
+import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Version hiding (version)
@@ -58,6 +58,10 @@ supportedChatVRange = mkVersionRange 1 currentChatVersion
groupNoDirectVRange :: VersionRange
groupNoDirectVRange = mkVersionRange 2 currentChatVersion
+-- version range that supports private messages from members in a group
+groupPrivateMessagesVRange :: VersionRange
+groupPrivateMessagesVRange = mkVersionRange 2 currentChatVersion
+
data ConnectionEntity
= RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact}
| RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember}
@@ -158,11 +162,28 @@ instance ToJSON SharedMsgId where
toJSON = strToJSON
toEncoding = strToJEncoding
+data MessageScope = MSGroup | MSDirect
+ deriving (Eq, Show, Generic)
+
+instance FromJSON MessageScope where
+ parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MS"
+
+instance ToJSON MessageScope where
+ toJSON = J.genericToJSON . enumJSON $ dropPrefix "MS"
+ toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MS"
+
+instance ToField MessageScope where
+ toField = toField . encodeJSON
+
+instance FromField MessageScope where
+ fromField = fromTextField_ decodeJSON
+
data MsgRef = MsgRef
{ msgId :: Maybe SharedMsgId,
sentAt :: UTCTime,
sent :: Bool,
- memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received
+ memberId :: Maybe MemberId, -- must be present in all group message references, both referencing sent and received
+ msgScope :: Maybe MessageScope
}
deriving (Eq, Show, Generic)
@@ -447,7 +468,13 @@ msgContentTag = \case
MCFile {} -> MCFile_
MCUnknown {tag} -> MCUnknown_ tag
-data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool}
+data ExtMsgContent = ExtMsgContent
+ { content :: MsgContent,
+ file :: Maybe FileInvitation,
+ ttl :: Maybe Int,
+ live :: Maybe Bool,
+ scope :: Maybe MessageScope
+ }
deriving (Eq, Show)
parseMsgContainer :: J.Object -> JT.Parser MsgContainer
@@ -456,10 +483,10 @@ parseMsgContainer v =
<|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc)
<|> MCSimple <$> mc
where
- mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" <*> v .:? "ttl" <*> v .:? "live"
+ mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" <*> v .:? "ttl" <*> v .:? "live" <*> v .:? "scope"
extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent
-extMsgContent mc file = ExtMsgContent mc file Nothing Nothing
+extMsgContent mc file = ExtMsgContent mc file Nothing Nothing Nothing
justTrue :: Bool -> Maybe Bool
justTrue True = Just True
@@ -503,7 +530,7 @@ msgContainerJSON = \case
MCSimple mc -> o $ msgContent mc
where
o = JM.fromList
- msgContent (ExtMsgContent c file ttl live) = ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) ["content" .= c]
+ msgContent (ExtMsgContent c file ttl live scope) = ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) ["content" .= c]
instance ToJSON MsgContent where
toJSON = \case
diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs
index 4bd092b7bd..025755c924 100644
--- a/src/Simplex/Chat/Store/Connections.hs
+++ b/src/Simplex/Chat/Store/Connections.hs
@@ -1,4 +1,5 @@
{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
@@ -6,25 +7,30 @@
module Simplex.Chat.Store.Connections
( getConnectionEntity,
+ getConnectionsToSubscribe,
+ unsetConnectionToSubscribe,
)
where
import Control.Applicative ((<|>))
import Control.Monad.Except
import Data.Int (Int64)
-import Data.Maybe (fromMaybe)
+import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import Data.Time.Clock (UTCTime (..))
-import Database.SQLite.Simple ((:.) (..))
+import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups
+import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
+import Simplex.Messaging.Agent.Protocol (ConnId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow')
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
+import Simplex.Messaging.Util (eitherToMaybe)
getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
getConnectionEntity db user@User {userId, userContactId} agentConnId = do
@@ -142,3 +148,17 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
userContact_ :: [(ConnReqContact, Maybe GroupId)] -> Either StoreError UserContact
userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId}
userContact_ _ = Left SEUserContactLinkNotFound
+
+getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity])
+getConnectionsToSubscribe db = do
+ aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1"
+ entities <- forM aConnIds $ \acId -> do
+ getUserByAConnId db acId >>= \case
+ Just user -> eitherToMaybe <$> runExceptT (getConnectionEntity db user acId)
+ Nothing -> pure Nothing
+ unsetConnectionToSubscribe db
+ let connIds = map (\(AgentConnId connId) -> connId) aConnIds
+ pure (connIds, catMaybes entities)
+
+unsetConnectionToSubscribe :: DB.Connection -> IO ()
+unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1"
diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs
index 7df2858e9f..609da128a7 100644
--- a/src/Simplex/Chat/Store/Direct.hs
+++ b/src/Simplex/Chat/Store/Direct.hs
@@ -75,6 +75,7 @@ import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
+import Simplex.Messaging.Protocol (SubscriptionMode (..))
import Simplex.Messaging.Version
getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection
@@ -109,8 +110,8 @@ deletePendingContactConnection db userId connId =
|]
(userId, connId, ConnContact)
-createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection
-createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do
+createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> IO PendingContactConnection
+createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode = do
createdAt <- getCurrentTime
customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile
let pccConnStatus = ConnJoined
@@ -119,10 +120,10 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou
[sql|
INSERT INTO connections (
user_id, agent_conn_id, conn_status, conn_type,
- via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?)
+ via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt))
+ ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate))
pccConnId <- insertedRowId db
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt}
@@ -162,17 +163,17 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do
"SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1"
(userId, cReqHash)
-createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection
-createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile = do
+createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection
+createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do
createdAt <- getCurrentTime
customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile
DB.execute
db
[sql|
INSERT INTO connections
- (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)
+ (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?)
|]
- (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt)
+ (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate)
pccConnId <- insertedRowId db
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt}
@@ -587,8 +588,8 @@ deleteContactRequest db User {userId} contactRequestId = do
(userId, userId, contactRequestId)
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
-createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact
-createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do
+createAcceptedContact :: DB.Connection -> User -> ConnId -> VersionRange -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> IO Contact
+createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId cReqChatVRange localDisplayName profileId profile userContactLinkId xContactId incognitoProfile subMode = do
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
createdAt <- getCurrentTime
customUserProfileId <- forM incognitoProfile $ \case
@@ -600,7 +601,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)"
(userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId)
contactId <- insertedRowId db
- activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt
+ activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt}
@@ -616,7 +617,7 @@ getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO C
getContact_ db user@User {userId} contactId deleted =
ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $
DB.query
- db
+ db
[sql|
SELECT
-- Contact
diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs
index fa085908e0..685d67e4de 100644
--- a/src/Simplex/Chat/Store/Files.hs
+++ b/src/Simplex/Chat/Store/Files.hs
@@ -100,6 +100,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
import qualified Simplex.Messaging.Crypto.File as CF
+import Simplex.Messaging.Protocol (SubscriptionMode (..))
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
getLiveSndFileTransfers db User {userId} = do
@@ -156,8 +157,8 @@ getPendingSndChunks db fileId connId =
|]
(fileId, connId)
-createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta
-createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do
+createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> SubscriptionMode -> IO FileTransferMeta
+createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize subMode = do
currentTs <- getCurrentTime
DB.execute
db
@@ -165,7 +166,7 @@ createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitatio
((userId, contactId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs))
fileId <- insertedRowId db
forM_ acId_ $ \acId -> do
- Connection {connId} <- createSndFileConnection_ db userId fileId acId
+ Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode
let fileStatus = FSNew
DB.execute
db
@@ -173,10 +174,10 @@ createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitatio
(fileId, fileStatus, fileInline, connId, currentTs, currentTs)
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
-createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> IO ()
-createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) = do
+createSndDirectFTConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO ()
+createSndDirectFTConnection db user@User {userId} fileId (cmdId, acId) subMode = do
currentTs <- getCurrentTime
- Connection {connId} <- createSndFileConnection_ db userId fileId acId
+ Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode
setCommandConnId db user cmdId connId
DB.execute
db
@@ -193,10 +194,10 @@ createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation
fileId <- insertedRowId db
pure FileTransferMeta {fileId, xftpSndFile = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False}
-createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> IO ()
-createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} = do
+createSndGroupFileTransferConnection :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO ()
+createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do
currentTs <- getCurrentTime
- Connection {connId} <- createSndFileConnection_ db userId fileId acId
+ Connection {connId} <- createSndFileConnection_ db userId fileId acId subMode
setCommandConnId db user cmdId connId
DB.execute
db
@@ -422,10 +423,10 @@ getChatRefByFileId db User {userId} fileId =
|]
(userId, fileId)
-createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection
-createSndFileConnection_ db userId fileId agentConnId = do
+createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection
+createSndFileConnection_ db userId fileId agentConnId subMode = do
currentTs <- getCurrentTime
- createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs
+ createConnection_ db userId ConnSndFile (Just fileId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode
updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO ()
updateSndFileStatus db SndFileTransfer {fileId, connId} status = do
@@ -644,14 +645,14 @@ getRcvFileTransfer db User {userId} fileId = do
_ -> pure Nothing
cancelled = fromMaybe False cancelled_
-acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> ExceptT StoreError IO AChatItem
-acceptRcvFileTransfer db user@User {userId} fileId (cmdId, acId) connStatus filePath = ExceptT $ do
+acceptRcvFileTransfer :: DB.Connection -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem
+acceptRcvFileTransfer db user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do
currentTs <- getCurrentTime
acceptRcvFT_ db user fileId filePath Nothing currentTs
DB.execute
db
- "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
- (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs)
+ "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)"
+ (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, subMode == SMOnlyCreate)
connId <- insertedRowId db
setCommandConnId db user cmdId connId
runExceptT $ getChatItemByFileId db user fileId
diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs
index 59f1b6090d..81fc37cce0 100644
--- a/src/Simplex/Chat/Store/Groups.hs
+++ b/src/Simplex/Chat/Store/Groups.hs
@@ -105,6 +105,7 @@ import Simplex.Messaging.Agent.Protocol (ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
+import Simplex.Messaging.Protocol (SubscriptionMode)
import Simplex.Messaging.Util (eitherToMaybe)
import Simplex.Messaging.Version
import UnliftIO.STM
@@ -135,8 +136,8 @@ toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just member
Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences))
toMaybeGroupMember _ _ = Nothing
-createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> ExceptT StoreError IO ()
-createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole =
+createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO ()
+createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole subMode =
checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do
currentTs <- getCurrentTime
DB.execute
@@ -144,7 +145,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName}
"INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
(userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs)
userContactLinkId <- insertedRowId db
- void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs
+ void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode
getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection
getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} =
@@ -536,7 +537,7 @@ groupMemberQuery =
LEFT JOIN connections c ON c.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
- where cc.user_id = ? AND cc.group_member_id = m.group_member_id
+ WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id
)
|]
@@ -614,12 +615,12 @@ getGroupInvitation db user groupId =
firstRow fromOnly (SEGroupNotFound groupId) $
DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId)
-createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> ExceptT StoreError IO GroupMember
-createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest =
+createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember
+createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest subMode =
createWithRandomId gVar $ \memId -> do
createdAt <- liftIO getCurrentTime
member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt
- void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt
+ void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode
pure member
where
createMember_ memberId createdAt = do
@@ -654,13 +655,13 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con
:. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt)
)
-createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> ExceptT StoreError IO ()
-createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange =
+createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> ExceptT StoreError IO ()
+createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) peerChatVRange subMode =
createWithRandomId gVar $ \memId -> do
createdAt <- liftIO getCurrentTime
insertMember_ (MemberId memId) createdAt
groupMemberId <- liftIO $ insertedRowId db
- Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt
+ Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 createdAt subMode
setCommandConnId db user cmdId connId
where
insertMember_ memberId createdAt =
@@ -713,15 +714,15 @@ getMemberInvitation db User {userId} groupMemberId =
fmap join . maybeFirstRow fromOnly $
DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId)
-createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> IO ()
-createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange = do
+createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionRange -> SubscriptionMode -> IO ()
+createMemberConnection db userId GroupMember {groupMemberId} agentConnId peerChatVRange subMode = do
currentTs <- getCurrentTime
- void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs
+ void $ createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode
-createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> IO ()
-createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange = do
+createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> VersionRange -> SubscriptionMode -> IO ()
+createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) peerChatVRange subMode = do
currentTs <- getCurrentTime
- Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs
+ Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange Nothing 0 currentTs subMode
setCommandConnId db user cmdId connId
updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO ()
@@ -920,14 +921,14 @@ getIntroduction_ db reMember toMember = ExceptT $ do
in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation}
toIntro _ = Left SEIntroNotFound
-createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> ExceptT StoreError IO GroupMember
-createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId = do
+createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember
+createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do
let mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange
cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn
currentTs <- liftIO getCurrentTime
newMember <- case directConnIds of
Just (directCmdId, directAgentConnId) -> do
- Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs
+ Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode
liftIO $ setCommandConnId db user directCmdId directConnId
(localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing
pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId}
@@ -936,18 +937,18 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM
pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId}
liftIO $ do
member <- createNewMember_ db user gInfo newMember currentTs
- conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs
+ conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId mcvr memberContactId cLevel currentTs subMode
liftIO $ setCommandConnId db user groupCmdId groupConnId
pure (member :: GroupMember) {activeConn = Just conn}
-createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> IO ()
-createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId = do
+createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionRange -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO ()
+createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do
let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn
currentTs <- getCurrentTime
- Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs
+ Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId mcvr viaContactId cLevel currentTs subMode
setCommandConnId db user groupCmdId groupConnId
forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do
- Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs
+ Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode
setCommandConnId db user directCmdId directConnId
contactId <- createMemberContact_ directConnId currentTs
updateMember_ contactId currentTs
@@ -977,7 +978,7 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId =
|]
[":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId]
-createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> IO Connection
+createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionRange -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection
createMemberConnection_ db userId groupMemberId agentConnId peerChatVRange viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId peerChatVRange viaContact Nothing Nothing
getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember))
diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs
index ddd59319d5..93b9ed612f 100644
--- a/src/Simplex/Chat/Store/Messages.hs
+++ b/src/Simplex/Chat/Store/Messages.hs
@@ -25,6 +25,7 @@ module Simplex.Chat.Store.Messages
createRcvMsgDeliveryEvent,
createPendingGroupMessage,
getPendingGroupMessages,
+ deleteMessage,
deletePendingGroupMessage,
deleteOldMessages,
updateChatTs,
@@ -289,6 +290,10 @@ getPendingGroupMessages db groupMemberId =
pendingGroupMessage (msgId, cmEventTag, msgBody, introId_) =
PendingGroupMessage {msgId, cmEventTag, msgBody, introId_}
+deleteMessage :: DB.Connection -> MessageId -> IO ()
+deleteMessage db msgId = do
+ DB.execute db "DELETE FROM messages WHERE message_id = ?" (Only msgId)
+
deletePendingGroupMessage :: DB.Connection -> Int64 -> MessageId -> IO ()
deletePendingGroupMessage db groupMemberId messageId =
DB.execute db "DELETE FROM pending_group_messages WHERE group_member_id = ? AND message_id = ?" (groupMemberId, messageId)
@@ -297,7 +302,7 @@ deleteOldMessages :: DB.Connection -> UTCTime -> IO ()
deleteOldMessages db createdAtCutoff = do
DB.execute db "DELETE FROM messages WHERE created_at <= ?" (Only createdAtCutoff)
-type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId)
+type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId, Maybe MessageScope)
updateChatTs :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> IO ()
updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirection of
@@ -320,14 +325,15 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon
createdByMsgId = if msgId == 0 then Nothing else Just msgId
quoteRow :: NewQuoteRow
quoteRow = case quotedItem of
- Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing)
- Just CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content} ->
- uncurry (quotedSharedMsgId,Just sentAt,Just content,,) $ case chatDir of
- CIQDirectSnd -> (Just True, Nothing)
- CIQDirectRcv -> (Just False, Nothing)
- CIQGroupSnd -> (Just True, Nothing)
- CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId)
- CIQGroupRcv Nothing -> (Just False, Nothing)
+ Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing)
+ Just CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content} -> do
+ let (quotedSent, quotedMemberId, quotedMsgScope) = case chatDir of
+ CIQDirectSnd -> (Just True, Nothing, Nothing)
+ CIQDirectRcv -> (Just False, Nothing, Nothing)
+ CIQGroupSnd messageScope -> (Just True, Nothing, Just messageScope)
+ CIQGroupRcv (Just GroupMember {memberId}) messageScope -> (Just False, Just memberId, Just messageScope)
+ CIQGroupRcv Nothing messageScope -> (Just False, Nothing, Just messageScope)
+ (quotedSharedMsgId, Just sentAt, Just content, quotedSent, quotedMemberId, quotedMsgScope)
createNewRcvChatItem :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c))
createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent timed live itemTs createdAt = do
@@ -338,19 +344,20 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent} shar
quotedMsg = cmToQuotedMsg chatMsgEvent
quoteRow :: NewQuoteRow
quoteRow = case quotedMsg of
- Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing)
- Just QuotedMsg {msgRef = MsgRef {msgId = sharedMsgId, sentAt, sent, memberId}, content} ->
- uncurry (sharedMsgId,Just sentAt,Just content,,) $ case chatDirection of
- CDDirectRcv _ -> (Just $ not sent, Nothing)
- CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ ->
- (Just $ Just userMemberId == memberId, memberId)
+ Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing)
+ Just QuotedMsg {msgRef = MsgRef {msgId = sharedMsgId, sentAt, sent, memberId, msgScope}, content} -> do
+ let (quotedSent, quotedMemberId, quotedMsgScope) = case chatDirection of
+ CDDirectRcv _ -> (Just $ not sent, Nothing, Nothing)
+ CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ ->
+ (Just $ Just userMemberId == memberId, memberId, msgScope)
+ (sharedMsgId, Just sentAt, Just content, quotedSent, quotedMemberId, quotedMsgScope)
createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId
createNewChatItemNoMsg db user chatDirection ciContent =
createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing False
where
quoteRow :: NewQuoteRow
- quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing)
+ quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing)
createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO ChatItemId
createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs createdAt = do
@@ -359,12 +366,12 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q
[sql|
INSERT INTO chat_items (
-- user and IDs
- user_id, created_by_msg_id, contact_id, group_id, group_member_id,
+ user_id, created_by_msg_id, contact_id, group_id, group_member_id, item_direct_group_member_id,
-- meta
item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at,
-- quote
- quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, quoted_message_scope
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
((userId, msgId_) :. idsRow :. itemRow :. quoteRow)
ciId <- insertedRowId db
@@ -373,12 +380,16 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q
where
itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime)
itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed
- idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64)
+ idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64)
idsRow = case chatDirection of
- CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing)
- CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing)
- CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId)
- CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing)
+ CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing)
+ CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing)
+ CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} messageScope -> case messageScope of
+ MSGroup -> (Nothing, Just groupId, Just groupMemberId, Nothing)
+ MSDirect -> (Nothing, Just groupId, Just groupMemberId, Just groupMemberId)
+ CDGroupSnd GroupInfo {groupId} directMember -> case directMember of
+ Nothing -> (Nothing, Just groupId, Nothing, Nothing)
+ Just GroupMember {groupMemberId} -> (Nothing, Just groupId, Nothing, Just groupMemberId)
ciTimedRow :: Maybe CITimed -> (Maybe Int, Maybe UTCTime)
ciTimedRow (Just CITimed {ttl, deleteAt}) = (Just ttl, deleteAt)
@@ -388,19 +399,21 @@ insertChatItemMessage_ :: DB.Connection -> ChatItemId -> MessageId -> UTCTime ->
insertChatItemMessage_ db ciId msgId ts = DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, ts, ts)
getChatItemQuote_ :: DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c)
-getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} =
+getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId, msgScope}, content} =
case chatDirection of
CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent)
- CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {memberId = senderMemberId} ->
+ CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {memberId = senderMemberId} _directMember ->
case memberId of
Just mId
- | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId
- | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId mId
+ | mId == userMemberId -> (`ciQuote` CIQGroupSnd messageScope) <$> getUserGroupChatItemId_ groupId
+ | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender) messageScope) <$> getGroupChatItemId_ groupId mId
| otherwise -> getGroupChatItemQuote_ groupId mId
- _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing
+ _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing messageScope
where
ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c
ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content
+ messageScope :: MessageScope
+ messageScope = fromMaybe MSGroup msgScope
getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect)
getDirectChatItemQuote_ contactId userSent = do
fmap ciQuoteDirect . maybeFirstRow fromOnly $
@@ -447,8 +460,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe
[":user_id" := userId, ":group_id" := groupId, ":member_id" := mId, ":msg_id" := msgId]
where
ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup
- ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing
- ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow
+ ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing messageScope
+ ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId $ CIQGroupRcv (Just $ toGroupMember userContactId memberRow) messageScope
getChatPreviews :: DB.Connection -> User -> Bool -> IO [AChat]
getChatPreviews db user withPCC = do
@@ -556,7 +569,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences,
-- quoted ChatItem
- ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent,
+ ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, i.quoted_message_scope,
-- quoted GroupMember
rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category,
rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
@@ -564,7 +577,11 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
-- deleted by GroupMember
dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category,
dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id,
- dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences
+ dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences,
+ -- direct GroupMember
+ dirm.group_member_id, dirm.group_id, dirm.member_id, dirm.member_role, dirm.member_category,
+ dirm.member_status, dirm.invited_by, dirm.local_display_name, dirm.contact_id, dirm.contact_profile_id, dirp.contact_profile_id,
+ dirp.display_name, dirp.full_name, dirp.image, dirp.contact_link, dirp.local_alias, dirp.preferences
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -590,6 +607,8 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
LEFT JOIN group_members dbm ON dbm.group_member_id = i.item_deleted_by_group_member_id
LEFT JOIN contact_profiles dbp ON dbp.contact_profile_id = COALESCE(dbm.member_profile_id, dbm.contact_profile_id)
+ LEFT JOIN group_members dirm ON dirm.group_member_id = i.item_direct_group_member_id
+ LEFT JOIN contact_profiles dirp ON dirp.contact_profile_id = COALESCE(dirm.member_profile_id, dirm.contact_profile_id)
WHERE g.user_id = ? AND mu.contact_id = ?
ORDER BY i.item_ts DESC
|]
@@ -967,10 +986,8 @@ toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect)
toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction <$> quotedSent
where
direction sent = if sent then CIQDirectSnd else CIQDirectRcv
-
-toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c)
-toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir =
- CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent)
+ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir =
+ CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent)
-- this function can be changed so it never fails, not only avoid failure on invalid json
toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
@@ -1013,37 +1030,60 @@ toDirectChatItemList currentTs (((Just itemId, Just itemTs, Just msgDir, Just it
either (const []) (: []) $ toDirectChatItem currentTs (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. quoteRow)
toDirectChatItemList _ _ = []
-type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow
+type GroupQuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MessageScope)
-type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow
+type GroupQuoteMemberRow = GroupQuoteRow :. MaybeGroupMemberRow
-toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup)
-toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_
+type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow :. GroupQuoteMemberRow :. MaybeGroupMemberRow :. MaybeGroupMemberRow
+
+toGroupQuote :: GroupQuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup)
+toGroupQuote qr@(_, _, _, _, quotedSent, msgScope) quotedMember_ =
+ toQuote qr $ direction quotedSent quotedMember_
where
- direction (Just True) _ = Just CIQGroupSnd
- direction (Just False) (Just member) = Just . CIQGroupRcv $ Just member
- direction (Just False) Nothing = Just $ CIQGroupRcv Nothing
+ direction (Just True) _ = Just $ CIQGroupSnd messageScope
+ direction (Just False) (Just member) = Just $ CIQGroupRcv (Just member) messageScope
+ direction (Just False) Nothing = Just $ CIQGroupRcv Nothing messageScope
direction _ _ = Nothing
+ messageScope = fromMaybe MSGroup msgScope
+ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _, _) dir =
+ CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent)
-- this function can be changed so it never fails, not only avoid failure on invalid json
-toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup)
-toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do
+toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteMemberRow :. MaybeGroupMemberRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup)
+toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_ :. directMemberRow_) = do
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
where
member_ = toMaybeGroupMember userContactId memberRow_
quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_
deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_
+ directMember_ = toMaybeGroupMember userContactId directMemberRow_
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of
(ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) ->
- Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus)
+ Right $ cItem SMDSnd (CIGroupSnd directMember_) ciStatus ciContent (maybeCIFile fileStatus)
(ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) ->
- Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing
+ Right $ cItem SMDSnd (CIGroupSnd directMember_) ciStatus ciContent Nothing
+ -- read of group chat item can be refactored so that direct member is not read for rcv items:
+ -- if item_direct_group_member_id is equal to group_member_id, then message scope is direct
(ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) ->
- Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus)
+ case directMember_ of
+ Just directMember
+ | sameMember member directMember ->
+ Right $ cItem SMDRcv (CIGroupRcv member MSDirect) ciStatus ciContent (maybeCIFile fileStatus)
+ | otherwise -> badItem
+ Nothing ->
+ Right $ cItem SMDRcv (CIGroupRcv member MSGroup) ciStatus ciContent (maybeCIFile fileStatus)
(ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) ->
- Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent Nothing
+ case directMember_ of
+ Just directMember
+ | sameMember member directMember ->
+ Right $ cItem SMDRcv (CIGroupRcv member MSDirect) ciStatus ciContent Nothing
+ | otherwise -> badItem
+ Nothing ->
+ Right $ cItem SMDRcv (CIGroupRcv member MSGroup) ciStatus ciContent Nothing
_ -> badItem
+ sameMember :: GroupMember -> GroupMember -> Bool
+ sameMember GroupMember {groupMemberId = gmId1} GroupMember {groupMemberId = gmId2} = gmId1 == gmId2
maybeCIFile :: CIFileStatus d -> Maybe (CIFile d)
maybeCIFile fileStatus =
case (fileId_, fileName_, fileSize_, fileProtocol_) of
@@ -1068,8 +1108,8 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir,
ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt}
toGroupChatItemList :: UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup]
-toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) =
- either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_)
+toGroupChatItemList currentTs userContactId (((Just itemId, Just itemTs, Just msgDir, Just itemContent, Just itemText, Just itemStatus, sharedMsgId) :. (Just itemDeleted, deletedTs, itemEdited, Just createdAt, Just updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_ :. directMemberRow_) =
+ either (const []) (: []) $ toGroupChatItem currentTs userContactId (((itemId, itemTs, msgDir, itemContent, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. fileRow) :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_ :. directMemberRow_)
toGroupChatItemList _ _ _ = []
getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem]
@@ -1484,7 +1524,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences,
-- quoted ChatItem
- ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent,
+ ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, i.quoted_message_scope,
-- quoted GroupMember
rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category,
rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
@@ -1492,7 +1532,11 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
-- deleted by GroupMember
dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category,
dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id,
- dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences
+ dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences,
+ -- direct GroupMember
+ dirm.group_member_id, dirm.group_id, dirm.member_id, dirm.member_role, dirm.member_category,
+ dirm.member_status, dirm.invited_by, dirm.local_display_name, dirm.contact_id, dirm.contact_profile_id, dirp.contact_profile_id,
+ dirp.display_name, dirp.full_name, dirp.image, dirp.contact_link, dirp.local_alias, dirp.preferences
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
@@ -1502,6 +1546,8 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
LEFT JOIN group_members dbm ON dbm.group_member_id = i.item_deleted_by_group_member_id
LEFT JOIN contact_profiles dbp ON dbp.contact_profile_id = COALESCE(dbm.member_profile_id, dbm.contact_profile_id)
+ LEFT JOIN group_members dirm ON dirm.group_member_id = i.item_direct_group_member_id
+ LEFT JOIN contact_profiles dirp ON dirp.contact_profile_id = COALESCE(dirm.member_profile_id, dirm.contact_profile_id)
WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ?
|]
(userId, groupId, itemId)
diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs
index b763f9a540..74e2d89d72 100644
--- a/src/Simplex/Chat/Store/Migrations.hs
+++ b/src/Simplex/Chat/Store/Migrations.hs
@@ -78,6 +78,8 @@ import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses
import Simplex.Chat.Migrations.M20230814_indexes
import Simplex.Chat.Migrations.M20230827_file_encryption
import Simplex.Chat.Migrations.M20230829_connections_chat_vrange
+import Simplex.Chat.Migrations.M20230903_connections_to_subscribe
+import Simplex.Chat.Migrations.M20230904_item_direct_group_member_id
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -155,7 +157,9 @@ schemaMigrations =
("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses),
("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes),
("20230827_file_encryption", m20230827_file_encryption, Just down_m20230827_file_encryption),
- ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange)
+ ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange),
+ ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe),
+ ("20230904_item_direct_group_member_id", m20230904_item_direct_group_member_id, Just down_m20230904_item_direct_group_member_id)
]
-- | The list of migrations in ascending order by date
diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs
index 0c2f1f636a..7f3c9841c0 100644
--- a/src/Simplex/Chat/Store/Profiles.hs
+++ b/src/Simplex/Chat/Store/Profiles.hs
@@ -80,7 +80,7 @@ import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
-import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..))
+import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode)
import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (safeDecodeUtf8)
@@ -293,8 +293,8 @@ getUserContactProfiles db User {userId} =
toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) -> (Profile)
toContactProfile (displayName, fullName, image, contactLink, preferences) = Profile {displayName, fullName, image, contactLink, preferences}
-createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> ExceptT StoreError IO ()
-createUserContactLink db User {userId} agentConnId cReq =
+createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> SubscriptionMode -> ExceptT StoreError IO ()
+createUserContactLink db User {userId} agentConnId cReq subMode =
checkConstraint SEDuplicateContactLink . liftIO $ do
currentTs <- getCurrentTime
DB.execute
@@ -302,7 +302,7 @@ createUserContactLink db User {userId} agentConnId cReq =
"INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)"
(userId, cReq, currentTs, currentTs)
userContactLinkId <- insertedRowId db
- void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs
+ void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode
getUserAddressConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection]
getUserAddressConnections db User {userId} = do
diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs
index 7ec307ab49..1e9f2888af 100644
--- a/src/Simplex/Chat/Store/Shared.hs
+++ b/src/Simplex/Chat/Store/Shared.hs
@@ -36,6 +36,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
+import Simplex.Messaging.Protocol (SubscriptionMode (..))
import Simplex.Messaging.Util (allFinally)
import Simplex.Messaging.Version
import UnliftIO.STM
@@ -158,8 +159,8 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer))
toMaybeConnection _ = Nothing
-createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection
-createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs = do
+createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection
+createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange minV maxV) viaContact viaUserContactLink customUserProfileId connLevel currentTs subMode = do
viaLinkGroupId :: Maybe Int64 <- fmap join . forM viaUserContactLink $ \ucLinkId ->
maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL" (userId, ucLinkId)
let viaGroupLink = isJust viaLinkGroupId
@@ -169,12 +170,12 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange
INSERT INTO connections (
user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type,
contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at,
- peer_chat_min_version, peer_chat_max_version
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ peer_chat_min_version, peer_chat_max_version, to_subscribe
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, ConnNew, connType)
:. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs)
- :. (minV, maxV)
+ :. (minV, maxV, subMode == SMOnlyCreate)
)
connId <- insertedRowId db
pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0}
diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs
index 36cec49d7c..f28795a374 100644
--- a/src/Simplex/Chat/Terminal/Input.hs
+++ b/src/Simplex/Chat/Terminal/Input.hs
@@ -73,19 +73,19 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
Right SendMessageBroadcast {} -> True
_ -> False
startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO ()
- startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do
+ startLiveMessage (Right (SendLiveMessage sendName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do
whenM (isNothing <$> readTVarIO liveMessageState) $ do
let s = T.unpack msg
int = case cType of SCTGroup -> 5000000; _ -> 3000000 :: Int
liveThreadId <- mkWeakThreadId =<< runLiveMessage int `forkFinally` const (atomically $ writeTVar liveMessageState Nothing)
promptThreadId <- mkWeakThreadId =<< forkIO blinkLivePrompt
atomically $ do
- let lm = LiveMessage {chatName, chatItemId = itemId, livePrompt = True, sentMsg = s, typedMsg = s, liveThreadId, promptThreadId}
+ let lm = LiveMessage {sendName, chatItemId = itemId, livePrompt = True, sentMsg = s, typedMsg = s, liveThreadId, promptThreadId}
writeTVar liveMessageState (Just lm)
modifyTVar termState $ \ts -> ts {inputString = s, inputPosition = length s, inputPrompt = liveInputPrompt lm}
where
- liveInputPrompt LiveMessage {chatName = n, livePrompt} =
- "> " <> chatNameStr n <> " [" <> (if livePrompt then "LIVE" else " ") <> "] "
+ liveInputPrompt LiveMessage {sendName = n, livePrompt} =
+ "> " <> sendNameStr n <> " [" <> (if livePrompt then "LIVE" else " ") <> "] "
runLiveMessage :: Int -> IO ()
runLiveMessage int = do
threadDelay int
@@ -123,8 +123,8 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
startLiveMessage _ _ = pure ()
sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO ChatResponse
-sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do
- let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg
+sendUpdatedLiveMessage cc sentMsg LiveMessage {sendName, chatItemId} live = do
+ let cmd = UpdateLiveMessage sendName chatItemId live $ T.pack sentMsg
either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
@@ -174,14 +174,14 @@ receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@C
let s = inputString ts
lm_ <- readTVar liveMessageState
case lm_ of
- Just LiveMessage {chatName}
+ Just LiveMessage {sendName}
| live -> do
writeTVar termState ts' {previousInput}
- writeTBQueue inputQ $ "/live " <> chatNameStr chatName
+ writeTBQueue inputQ $ "/live " <> sendNameStr sendName
| otherwise ->
writeTVar termState ts' {inputPrompt = "> ", previousInput}
where
- previousInput = chatNameStr chatName <> " " <> s
+ previousInput = sendNameStr sendName <> " " <> s
_
| live -> when (isSend s) $ do
writeTVar termState ts' {previousInput = s}
diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs
index ce68d715fe..1a36380282 100644
--- a/src/Simplex/Chat/Terminal/Output.hs
+++ b/src/Simplex/Chat/Terminal/Output.hs
@@ -55,7 +55,7 @@ data AutoCompleteState = ACState
}
data LiveMessage = LiveMessage
- { chatName :: ChatName,
+ { sendName :: SendName,
chatItemId :: ChatItemId,
livePrompt :: Bool,
sentMsg :: String,
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index 2d77cbe771..bcef3cbff9 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -330,12 +330,12 @@ data GroupSummary = GroupSummary
instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions
-data ContactOrGroup = CGContact Contact | CGGroup Group
+data ContactOrGroup = CGContact Contact | CGGroup GroupInfo
contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId)
contactAndGroupIds = \case
CGContact Contact {contactId} -> (Just contactId, Nothing)
- CGGroup (Group GroupInfo {groupId} _) -> (Nothing, Just groupId)
+ CGGroup GroupInfo {groupId} -> (Nothing, Just groupId)
-- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties)
data ChatSettings = ChatSettings
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index 1a740bef5f..4656ea5b20 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -322,14 +322,35 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
testViewChat :: AChat -> [StyledString]
testViewChat (AChat _ Chat {chatInfo, chatItems}) = [sShow $ map toChatView chatItems]
where
- toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text), Maybe String)
- toChatView ci@(CChatItem dir ChatItem {quotedItem, file}) =
- ((msgDirectionInt $ toMsgDirection dir, testViewItem ci (chatInfoMembership chatInfo)), qItem, fPath)
+ toChatView :: CChatItem c -> ((Int, String, Text), Maybe (Int, String, Text), Maybe String)
+ toChatView ci@(CChatItem dir ChatItem {chatDir, quotedItem, file}) =
+ (item, qItem, fPath)
where
+ item =
+ ( msgDirectionInt $ toMsgDirection dir,
+ directMemberName,
+ testViewItem ci (chatInfoMembership chatInfo)
+ )
+ directMemberName = case chatDir of
+ CIGroupSnd (Just GroupMember {localDisplayName = n}) -> T.unpack n
+ CIGroupRcv GroupMember {localDisplayName = n} MSDirect -> T.unpack n
+ _ -> ""
qItem = case quotedItem of
Nothing -> Nothing
Just CIQuote {chatDir = quoteDir, content} ->
- Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content)
+ Just
+ ( msgDirectionInt $ quoteMsgDirection quoteDir,
+ qMsgScope,
+ msgContentText content
+ )
+ where
+ qMsgScope = case quoteDir of
+ CIQGroupSnd ms -> msgScopeText ms
+ CIQGroupRcv _ ms -> msgScopeText ms
+ _ -> ""
+ msgScopeText ms = case ms of
+ MSGroup -> "group"
+ MSDirect -> "direct"
fPath = case file of
Just CIFile {fileSource = Just (CryptoFile fp _)} -> Just fp
_ -> Nothing
@@ -380,7 +401,7 @@ viewUsersList = mapMaybe userInfo . sortOn ldn
muted :: ChatInfo c -> CIDirection c d -> Bool
muted chat chatDir = case (chat, chatDir) of
(DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True
- (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> True
+ (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _ _) -> True
_ -> False
viewGroupSubscribed :: GroupInfo -> [StyledString]
@@ -403,8 +424,9 @@ viewChats ts tz = concatMap chatPreview . reverse
where
chatName = case chat of
DirectChat ct -> [" " <> ttyToContact' ct]
- GroupChat g -> [" " <> ttyToGroup g]
+ GroupChat g -> [" " <> ttyToGroup' g]
_ -> []
+ ttyToGroup' g@GroupInfo {localDisplayName = n} = membershipIncognito g <> ttyTo ("#" <> n <> " ")
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString]
viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} doShow ts tz =
@@ -426,20 +448,20 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file}
where
quote = maybe [] (directQuote chatDir) quotedItem
GroupChat g -> case chatDir of
- CIGroupSnd -> case content of
+ CIGroupSnd directMember -> case content of
CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc
CISndGroupInvitation {} -> showSndItemProhibited to
_ -> showSndItem to
where
- to = ttyToGroup g
- CIGroupRcv m -> case content of
+ to = ttyToGroup g directMember
+ CIGroupRcv m msgScope -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta
CIRcvGroupInvitation {} -> showRcvItemProhibited from
- CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) quote meta [plainContent content] False
+ CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m msgScope) quote meta [plainContent content] False
_ -> showRcvItem from
where
- from = ttyFromGroup g m
+ from = ttyFromGroup g m msgScope
where
quote = maybe [] (groupQuote g) quotedItem
_ -> []
@@ -531,18 +553,18 @@ viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemEdited, itemLive}
where
quote = maybe [] (directQuote chatDir) quotedItem
GroupChat g -> case chatDir of
- CIGroupRcv m -> case content of
+ CIGroupRcv m msgScope -> case content of
CIRcvMsgContent mc
| itemLive == Just True && not liveItems -> []
| otherwise -> viewReceivedUpdatedMessage from quote mc ts tz meta
_ -> []
where
- from = if itemEdited then ttyFromGroupEdited g m else ttyFromGroup g m
- CIGroupSnd -> case content of
+ from = if itemEdited then ttyFromGroupEdited g m msgScope else ttyFromGroup g m msgScope
+ CIGroupSnd directMember -> case content of
CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts tz meta
_ -> []
where
- to = if itemEdited then ttyToGroupEdited g else ttyToGroup g
+ to = if itemEdited then ttyToGroupEdited g directMember else ttyToGroup g directMember
where
quote = maybe [] (groupQuote g) quotedItem
_ -> []
@@ -567,7 +589,8 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem
GroupChat g -> case ciMsgContent deletedContent of
Just mc ->
let m = chatItemMember g ci
- in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts tz meta
+ msgScope = directMemberToMsgScope $ ciDirDirectMember chatDir
+ in viewReceivedMessage (ttyFromGroupDeleted g m msgScope deletedText_) [] mc ts tz meta
_ -> prohibited
_ -> prohibited
where
@@ -586,14 +609,14 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md
where
from = ttyFromContact c
reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">"
- (GroupChat g, CIGroupRcv m) -> case ciMsgContent content of
+ (GroupChat g, CIGroupRcv m messageScope) -> case ciMsgContent content of
Just mc -> view from $ reactionMsg mc
_ -> []
where
- from = ttyFromGroup g m
+ from = ttyFromGroup g m messageScope
reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir
(_, CIDirectSnd) -> [sentText]
- (_, CIGroupSnd) -> [sentText]
+ (_, CIGroupSnd _) -> [sentText]
where
view from msg
| showReactions = viewReceivedReaction from msg reactionText ts tz sentAt
@@ -621,13 +644,13 @@ groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQu
sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember
sentByMember GroupInfo {membership} = \case
- CIQGroupSnd -> Just membership
- CIQGroupRcv m -> m
+ CIQGroupSnd _ -> Just membership
+ CIQGroupRcv m _ -> m
sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> GroupMember
sentByMember' GroupInfo {membership} = \case
- CIGroupSnd -> membership
- CIGroupRcv m -> m
+ CIGroupSnd _ -> membership
+ CIGroupRcv m _ -> m
quoteText :: MsgContent -> StyledString -> [StyledString]
quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc
@@ -1319,8 +1342,9 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
uploadingFile :: StyledString -> AChatItem -> [StyledString]
uploadingFile status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd}) =
[status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c]
-uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd}) =
- [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g]
+uploadingFile status (AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd directMember}) =
+ let forMember = maybe "" (\GroupMember {localDisplayName = m} -> styled (colored Blue) $ " @" <> m <> " (direct)") directMember
+ in [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g <> forMember]
uploadingFile status _ = [status <> " uploading file"] -- shouldn't happen
sndFile :: SndFileTransfer -> StyledString
@@ -1352,7 +1376,7 @@ savingFile' :: Bool -> AChatItem -> [StyledString]
savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) =
let from = case (chat, chatDir) of
(DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c
- (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m
+ (_, CIGroupRcv GroupMember {localDisplayName = m} _) -> " from " <> ttyContact m
_ -> ""
in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr
where
@@ -1366,7 +1390,7 @@ savingFile' _ _ = ["saving file"] -- shouldn't happen
receivingFile_' :: StyledString -> AChatItem -> [StyledString]
receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) =
[status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c]
-receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) =
+receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv GroupMember {localDisplayName = m} _}) =
[status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact m]
receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen
@@ -1582,7 +1606,7 @@ viewChatError logLevel = \case
CEXFTPSndFile fileId aFileId e -> ["error sending XFTP file " <> sShow fileId <> ", agent file id " <> sShow aFileId <> ": " <> sShow e | logLevel == CLLError]
CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"]
CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."]
- CEInvalidQuote -> ["cannot reply to this message"]
+ CEInvalidQuote -> ["invalid message reply"]
CEInvalidChatItemUpdate -> ["cannot update this item"]
CEInvalidChatItemDelete -> ["cannot delete this item"]
CEHasCurrentCall -> ["call already in progress"]
@@ -1597,6 +1621,7 @@ viewChatError logLevel = \case
CEAgentCommandError e -> ["agent command error: " <> plain e]
CEInvalidFileDescription e -> ["invalid file description: " <> plain e]
CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"]
+ CEPeerChatVRangeIncompatible -> ["peer chat protocol version range incompatible"]
CEInternalError e -> ["internal chat error: " <> plain e]
CEException e -> ["exception: " <> plain e]
-- e -> ["chat error: " <> sShow e]
@@ -1737,19 +1762,24 @@ ttyFullGroup :: GroupInfo -> StyledString
ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName}} =
ttyGroup g <> optFullName g fullName
-ttyFromGroup :: GroupInfo -> GroupMember -> StyledString
-ttyFromGroup g m = membershipIncognito g <> ttyFrom (fromGroup_ g m)
+ttyFromGroup :: GroupInfo -> GroupMember -> MessageScope -> StyledString
+ttyFromGroup g m ms = membershipIncognito g <> ttyFrom (fromGroup_ g m ms)
-ttyFromGroupEdited :: GroupInfo -> GroupMember -> StyledString
-ttyFromGroupEdited g m = membershipIncognito g <> ttyFrom (fromGroup_ g m <> "[edited] ")
+ttyFromGroupEdited :: GroupInfo -> GroupMember -> MessageScope -> StyledString
+ttyFromGroupEdited g m ms = membershipIncognito g <> ttyFrom (fromGroup_ g m ms <> "[edited] ")
-ttyFromGroupDeleted :: GroupInfo -> GroupMember -> Maybe Text -> StyledString
-ttyFromGroupDeleted g m deletedText_ =
- membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_)
+ttyFromGroupDeleted :: GroupInfo -> GroupMember -> MessageScope -> Maybe Text -> StyledString
+ttyFromGroupDeleted g m ms deletedText_ =
+ membershipIncognito g <> ttyFrom (fromGroup_ g m ms <> maybe "" (\t -> "[" <> t <> "] ") deletedText_)
-fromGroup_ :: GroupInfo -> GroupMember -> Text
-fromGroup_ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} =
- "#" <> g <> " " <> m <> "> "
+fromGroup_ :: GroupInfo -> GroupMember -> MessageScope -> Text
+fromGroup_ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} ms =
+ "#" <> g <> " " <> m <> fromGroupScope ms <> "> "
+
+fromGroupScope :: MessageScope -> Text
+fromGroupScope = \case
+ MSGroup -> ""
+ MSDirect -> " (direct)"
ttyFrom :: Text -> StyledString
ttyFrom = styled $ colored Yellow
@@ -1757,13 +1787,18 @@ ttyFrom = styled $ colored Yellow
ttyTo :: Text -> StyledString
ttyTo = styled $ colored Cyan
-ttyToGroup :: GroupInfo -> StyledString
-ttyToGroup g@GroupInfo {localDisplayName = n} =
- membershipIncognito g <> ttyTo ("#" <> n <> " ")
+ttyToGroup :: GroupInfo -> Maybe GroupMember -> StyledString
+ttyToGroup g@GroupInfo {localDisplayName = n} dirMem =
+ membershipIncognito g <> ttyTo ("#" <> n <> toDirectMember dirMem <> " ")
-ttyToGroupEdited :: GroupInfo -> StyledString
-ttyToGroupEdited g@GroupInfo {localDisplayName = n} =
- membershipIncognito g <> ttyTo ("#" <> n <> " [edited] ")
+ttyToGroupEdited :: GroupInfo -> Maybe GroupMember -> StyledString
+ttyToGroupEdited g@GroupInfo {localDisplayName = n} dirMem =
+ membershipIncognito g <> ttyTo ("#" <> n <> toDirectMember dirMem <> " [edited] ")
+
+toDirectMember :: Maybe GroupMember -> Text
+toDirectMember = \case
+ Nothing -> ""
+ Just GroupMember {localDisplayName = m} -> " @" <> m <> " (direct)"
ttyFilePath :: FilePath -> StyledString
ttyFilePath = plain
diff --git a/stack.yaml b/stack.yaml
index 9c6b35432f..18d5afe8b9 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
- commit: 351f42650c57f310fc1ea858ff9b7178823f1fd4
+ commit: 0cabe0690beee90f460ad7bada72294222e7e109
- github: kazu-yamamoto/http2
commit: b5a1b7200cf5bc7044af34ba325284271f6dff25
# - ../direct-sqlcipher
diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs
index 9e5d4fe1c0..de6353d2e0 100644
--- a/tests/ChatClient.hs
+++ b/tests/ChatClient.hs
@@ -259,7 +259,7 @@ getTermLine cc =
Just s -> do
-- remove condition to always echo virtual terminal
when (printOutput cc) $ do
- -- when True $ do
+ -- when True $ do
name <- userName cc
putStrLn $ name <> ": " <> s
pure s
diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs
index 4bb87b1e96..3db4052226 100644
--- a/tests/ChatTests/Direct.hs
+++ b/tests/ChatTests/Direct.hs
@@ -57,6 +57,8 @@ chatDirectTests = do
it "start/stop/export/import chat" testMaintenanceMode
it "export/import chat with files" testMaintenanceModeWithFiles
it "encrypt/decrypt database" testDatabaseEncryption
+ describe "coordination between app and NSE" $ do
+ it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE
describe "mute/unmute messages" $ do
it "mute/unmute contact" testMuteContact
it "mute/unmute group" testMuteGroup
@@ -970,6 +972,35 @@ testDatabaseEncryption tmp = do
withTestChat tmp "alice" $ \alice -> do
testChatWorking alice bob
+testSubscribeAppNSE :: HasCallStack => FilePath -> IO ()
+testSubscribeAppNSE tmp =
+ withNewTestChat tmp "bob" bobProfile $ \bob -> do
+ withNewTestChat tmp "alice" aliceProfile $ \alice -> do
+ withTestChatOpts tmp testOpts {maintenance = True} "alice" $ \nseAlice -> do
+ alice ##> "/_app suspend 1"
+ alice <## "ok"
+ alice <## "chat suspended"
+ nseAlice ##> "/_start subscribe=off expire=off xftp=off"
+ nseAlice <## "chat started"
+ nseAlice ##> "/ad"
+ cLink <- getContactLink nseAlice True
+ bob ##> ("/c " <> cLink)
+ bob <## "connection request sent!"
+ (nseAlice )
+ alice ##> "/_app activate"
+ alice <## "ok"
+ alice <## "Your address is active! To show: /sa"
+ alice <## "bob (Bob) wants to connect to you!"
+ alice <## "to accept: /ac bob"
+ alice <## "to reject: /rc bob (the sender will NOT be notified)"
+ alice ##> "/ac bob"
+ alice <## "bob (Bob): accepting contact request..."
+ concurrently_
+ (bob <## "alice (Alice): contact is connected")
+ (alice <## "bob (Bob): contact is connected")
+ threadDelay 100000
+ alice <##> bob
+
testMuteContact :: HasCallStack => FilePath -> IO ()
testMuteContact =
testChat2 aliceProfile bobProfile $
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index d476285fcd..6c804659e7 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -8,8 +8,9 @@ import ChatTests.Utils
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Monad (when)
+import qualified Data.ByteString.Char8 as B
import qualified Data.Text as T
-import Simplex.Chat.Controller (ChatConfig (..))
+import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..))
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
import Simplex.Chat.Types (GroupMemberRole (..))
@@ -81,6 +82,21 @@ chatGroupTests = do
testNoDirect4 _1 _0 _1 False False False -- False False True
testNoDirect4 _1 _1 _0 False False False
testNoDirect4 _1 _1 _1 False False False
+ describe "group direct messages" $ do
+ it "should send group direct messages" testGroupDirectMessages
+ it "should create group direct messages chat items" testGroupDirectMessagesItems
+ it "should send group direct quotes" testGroupDirectQuotes
+ it "should create group direct quotes chat items" testGroupDirectQuotesItems
+ it "should send group direct XFTP files" testGroupDirectFilesXFTP
+ it "should send group direct SMP files" testGroupDirectFilesSMP
+ it "should cancel sent group direct XFTP file" testGroupDirectCancelFileXFTP
+ it "should send group direct quotes with files" testGroupDirectQuotesFiles
+ it "should update group direct message" testGroupDirectUpdate
+ it "should delete group direct message" testGroupDirectDelete
+ it "should send group direct live message" testGroupDirectLiveMessage
+ it "should send group direct message reactions" testGroupDirectReactions
+ it "should prohibit group direct messages based on preference" testGroupDirectProhibitPreference
+ it "should prohibit group direct messages if peer version doesn't support" testGroupDirectProhibitNotSupported
where
_0 = supportedChatVRange -- don't create direct connections
_1 = groupCreateDirectVRange
@@ -804,7 +820,7 @@ testGroupMessageQuotedReply =
(bob <# "#team alice> hello! how are you?")
(cath <# "#team alice> hello! how are you?")
threadDelay 1000000
- bob `send` "> #team @alice (hello) hello, all good, you?"
+ bob `send` "> #team >@alice (hello) hello, all good, you?"
bob <# "#team > alice hello! how are you?"
bob <## " hello, all good, you?"
concurrently_
@@ -819,7 +835,7 @@ testGroupMessageQuotedReply =
bob #$> ("/_get chat #1 count=2", chat', [((0, "hello! how are you?"), Nothing), ((1, "hello, all good, you?"), Just (0, "hello! how are you?"))])
alice #$> ("/_get chat #1 count=2", chat', [((1, "hello! how are you?"), Nothing), ((0, "hello, all good, you?"), Just (1, "hello! how are you?"))])
cath #$> ("/_get chat #1 count=2", chat', [((0, "hello! how are you?"), Nothing), ((0, "hello, all good, you?"), Just (0, "hello! how are you?"))])
- bob `send` "> #team bob (hello, all good) will tell more"
+ bob `send` "> #team >@bob (hello, all good) will tell more"
bob <# "#team > bob hello, all good, you?"
bob <## " will tell more"
concurrently_
@@ -835,7 +851,7 @@ testGroupMessageQuotedReply =
alice #$> ("/_get chat #1 count=1", chat', [((0, "will tell more"), Just (0, "hello, all good, you?"))])
cath #$> ("/_get chat #1 count=1", chat', [((0, "will tell more"), Just (0, "hello, all good, you?"))])
threadDelay 1000000
- cath `send` "> #team bob (hello) hi there!"
+ cath `send` "> #team >@bob (hello) hi there!"
cath <# "#team > bob hello, all good, you?"
cath <## " hi there!"
concurrently_
@@ -891,7 +907,7 @@ testGroupMessageUpdate =
threadDelay 1000000
-- alice, bob: msg id 6, cath: msg id 5
- bob `send` "> #team @alice (hey) hi alice"
+ bob `send` "> #team >@alice (hey) hi alice"
bob <# "#team > alice hey 👋"
bob <## " hi alice"
concurrently_
@@ -918,7 +934,7 @@ testGroupMessageUpdate =
alice #$> ("/_update item #1 " <> msgItemId2 <> " text updating bob's message", id, "cannot update this item")
threadDelay 1000000
- cath `send` "> #team @alice (greetings) greetings!"
+ cath `send` "> #team >@alice (greetings) greetings!"
cath <# "#team > alice greetings 🤝"
cath <## " greetings!"
concurrently_
@@ -994,7 +1010,6 @@ testGroupMessageEditHistory =
alice ##> ("/_update item #1 " <> aliceItemId <> " text hey there")
alice <# "#team [edited] hey there"
- bob <# "#team alice> [edited] hey there"
alice ##> "/item info #team hey"
alice <##. "sent at: "
@@ -1004,10 +1019,7 @@ testGroupMessageEditHistory =
alice .<## ": hey 👋"
alice .<## ": hello!"
bob ##> "/item info #team hey"
- bob <##. "sent at: "
- bob <##. "received at: "
- bob <## "message history:"
- bob .<## ": hey there"
+ bob <## "message not found by text: hey"
testGroupMessageDelete :: HasCallStack => FilePath -> IO ()
testGroupMessageDelete =
@@ -1031,7 +1043,7 @@ testGroupMessageDelete =
threadDelay 1000000
-- alice: msg id 5, bob: msg id 6, cath: msg id 5
- bob `send` "> #team @alice (hello) hi alic"
+ bob `send` "> #team >@alice (hello) hi alic"
bob <# "#team > alice hello!"
bob <## " hi alic"
concurrently_
@@ -1060,14 +1072,10 @@ testGroupMessageDelete =
bob ##> ("/_update item #1 " <> msgItemId3 <> " text hi alice")
bob <# "#team [edited] > alice hello!"
bob <## " hi alice"
- concurrently_
- (alice <# "#team bob> [edited] hi alice")
- ( do
- cath <# "#team bob> [edited] > alice hello!"
- cath <## " hi alice"
- )
+ cath <# "#team bob> [edited] > alice hello!"
+ cath <## " hi alice"
- alice #$> ("/_get chat #1 count=1", chat', [((0, "hi alice"), Nothing)])
+ alice #$> ("/_get chat #1 count=1", chat', [((0, "connected"), Nothing)])
bob #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((1, "hi alice"), Just (0, "hello!"))])
cath #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((0, "hi alice"), Just (0, "hello!"))])
@@ -2686,3 +2694,534 @@ testNoGroupDirectConns4Members hostVRange mem2VRange mem3VRange mem4VRange noCon
cc1 <## ("no contact " <> name2)
cc2 ##> ("@" <> name1 <> " hi")
cc2 <## ("no contact " <> name1)
+
+testGroupDirectMessages :: HasCallStack => FilePath -> IO ()
+testGroupDirectMessages =
+ testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
+ createGroup3 "team" alice bob cath
+ connectUsers alice dan
+ addMember "team" alice dan GRMember
+ dan ##> "/j team"
+ concurrentlyN_
+ [ alice <## "#team: dan joined the group",
+ do
+ dan <## "#team: you joined the group"
+ dan
+ <### [ "#team: member bob (Bob) is connected",
+ "#team: member cath (Catherine) is connected"
+ ],
+ aliceAddedDan bob,
+ aliceAddedDan cath
+ ]
+
+ alice #> "#team hi"
+ bob <# "#team alice> hi"
+ cath <# "#team alice> hi"
+ dan <# "#team alice> hi"
+
+ alice `send` "#team @bob hi bob"
+ alice <# "#team @bob (direct) hi bob"
+ bob <# "#team alice (direct)> hi bob"
+
+ bob `send` "#team @alice hi alice"
+ bob <# "#team @alice (direct) hi alice"
+ alice <# "#team bob (direct)> hi alice"
+
+ dan #> "#team hello"
+ alice <# "#team dan> hello"
+ bob <# "#team dan> hello"
+ cath <# "#team dan> hello"
+
+ bob `send` "#team @cath hi cath"
+ bob <# "#team @cath (direct) hi cath"
+ cath <# "#team bob (direct)> hi cath"
+
+ cath `send` "#team @bob hello bob"
+ cath <# "#team @bob (direct) hello bob"
+ bob <# "#team cath (direct)> hello bob"
+ where
+ aliceAddedDan :: HasCallStack => TestCC -> IO ()
+ aliceAddedDan cc = do
+ cc <## "#team: alice added dan (Daniel) to the group (connecting...)"
+ cc <## "#team: new member dan is connected"
+
+testGroupDirectMessagesItems :: HasCallStack => FilePath -> IO ()
+testGroupDirectMessagesItems =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+ threadDelay 1000000
+
+ alice #> "#team hi"
+ bob <# "#team alice> hi"
+ cath <# "#team alice> hi"
+ threadDelay 1000000
+
+ alice `send` "#team @bob hi bob"
+ alice <# "#team @bob (direct) hi bob"
+ bob <# "#team alice (direct)> hi bob"
+ threadDelay 1000000
+
+ bob `send` "#team @alice hi alice"
+ bob <# "#team @alice (direct) hi alice"
+ alice <# "#team bob (direct)> hi alice"
+ threadDelay 1000000
+
+ alice #$> ("/_get chat #1 count=4", mapChat, [(0, "", "connected"), (1, "", "hi"), (1, "bob", "hi bob"), (0, "bob", "hi alice")])
+ bob #$> ("/_get chat #1 count=4", mapChat, [(0, "", "connected"), (0, "", "hi"), (0, "alice", "hi bob"), (1, "alice", "hi alice")])
+ cath #$> ("/_get chat #1 count=2", mapChat, [(0, "", "connected"), (0, "", "hi")])
+ where
+ mapChat = map (\(a, _, _) -> a) . chat'''
+
+testGroupDirectQuotes :: HasCallStack => FilePath -> IO ()
+testGroupDirectQuotes =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice #> "#team 1-g-a"
+ bob <# "#team alice> 1-g-a"
+ cath <# "#team alice> 1-g-a"
+
+ bob #> "#team 2-g-b"
+ alice <# "#team bob> 2-g-b"
+ cath <# "#team bob> 2-g-b"
+
+ cath #> "#team 3-g-c"
+ alice <# "#team cath> 3-g-c"
+ bob <# "#team cath> 3-g-c"
+
+ alice `send` "#team @bob 4-p-ab"
+ alice <# "#team @bob (direct) 4-p-ab"
+ bob <# "#team alice (direct)> 4-p-ab"
+
+ bob `send` "#team @alice 5-p-ba"
+ bob <# "#team @alice (direct) 5-p-ba"
+ alice <# "#team bob (direct)> 5-p-ba"
+
+ alice `send` "#team @cath 6-p-ac"
+ alice <# "#team @cath (direct) 6-p-ac"
+ cath <# "#team alice (direct)> 6-p-ac"
+
+ cath `send` "#team @alice 7-p-ca"
+ cath <# "#team @alice (direct) 7-p-ca"
+ alice <# "#team cath (direct)> 7-p-ca"
+
+ -- quotes
+
+ alice `send` "> #team @bob (1-g-a) 8-pq-ab"
+ alice <# "#team @bob (direct) > alice 1-g-a"
+ alice <## " 8-pq-ab"
+ bob <# "#team alice (direct)> > alice 1-g-a"
+ bob <## " 8-pq-ab"
+
+ alice `send` "> #team @bob (2-g-b) 9-pq-ab"
+ alice <# "#team @bob (direct) > bob 2-g-b"
+ alice <## " 9-pq-ab"
+ bob <# "#team alice (direct)> > bob 2-g-b"
+ bob <## " 9-pq-ab"
+
+ alice `send` "> #team >@cath @bob (3-g-c) 10-pq-ab"
+ alice <# "#team @bob (direct) > cath 3-g-c"
+ alice <## " 10-pq-ab"
+ bob <# "#team alice (direct)> > cath 3-g-c"
+ bob <## " 10-pq-ab"
+
+ alice `send` "> #team @bob (4-p-ab) 11-pq-ab"
+ alice <# "#team @bob (direct) > alice 4-p-ab"
+ alice <## " 11-pq-ab"
+ bob <# "#team alice (direct)> > alice 4-p-ab"
+ bob <## " 11-pq-ab"
+
+ alice `send` "> #team >@bob @bob (5-p-ba) 12-pq-ab"
+ alice <# "#team @bob (direct) > bob 5-p-ba"
+ alice <## " 12-pq-ab"
+ bob <# "#team alice (direct)> > bob 5-p-ba"
+ bob <## " 12-pq-ab"
+
+ alice `send` "> #team @bob (6-p-ac) 13-pq-ab"
+ alice <## "> #team @bob (6-p-ac) 13-pq-ab"
+ alice <## "invalid message reply"
+
+ alice `send` "> #team @bob (7-p-ca) 14-pq-ab"
+ alice <## "> #team @bob (7-p-ca) 14-pq-ab"
+ alice <## "invalid message reply"
+
+ alice `send` "> #team (4-p-ab) 15-gq-a"
+ alice <## "> #team (4-p-ab) 15-gq-a"
+ alice <## "invalid message reply"
+
+ alice `send` "> #team (5-p-ba) 16-gq-a"
+ alice <## "> #team (5-p-ba) 16-gq-a"
+ alice <## "invalid message reply"
+
+testGroupDirectQuotesItems :: HasCallStack => FilePath -> IO ()
+testGroupDirectQuotesItems =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice #> "#team 1-g-a"
+ bob <# "#team alice> 1-g-a"
+ cath <# "#team alice> 1-g-a"
+
+ alice `send` "#team @bob 2-p-ab"
+ alice <# "#team @bob (direct) 2-p-ab"
+ bob <# "#team alice (direct)> 2-p-ab"
+
+ bob `send` "#team @alice 3-p-ba"
+ bob <# "#team @alice (direct) 3-p-ba"
+ alice <# "#team bob (direct)> 3-p-ba"
+ threadDelay 1000000
+
+ -- quotes
+
+ alice `send` "> #team @bob (1-g-a) 4-pq-ab"
+ alice <# "#team @bob (direct) > alice 1-g-a"
+ alice <## " 4-pq-ab"
+ bob <# "#team alice (direct)> > alice 1-g-a"
+ bob <## " 4-pq-ab"
+ threadDelay 1000000
+
+ alice `send` "> #team @bob (2-p-ab) 5-pq-ab"
+ alice <# "#team @bob (direct) > alice 2-p-ab"
+ alice <## " 5-pq-ab"
+ bob <# "#team alice (direct)> > alice 2-p-ab"
+ bob <## " 5-pq-ab"
+ threadDelay 1000000
+
+ alice `send` "> #team >@bob @bob (3-p-ba) 6-pq-ab"
+ alice <# "#team @bob (direct) > bob 3-p-ba"
+ alice <## " 6-pq-ab"
+ bob <# "#team alice (direct)> > bob 3-p-ba"
+ bob <## " 6-pq-ab"
+
+ alice
+ #$> ( "/_get chat #1 count=3",
+ mapChat,
+ [ ((1, "bob", "4-pq-ab"), Just (1, "group", "1-g-a")),
+ ((1, "bob", "5-pq-ab"), Just (1, "direct", "2-p-ab")),
+ ((1, "bob", "6-pq-ab"), Just (0, "direct", "3-p-ba"))
+ ]
+ )
+ bob
+ #$> ( "/_get chat #1 count=3",
+ mapChat,
+ [ ((0, "alice", "4-pq-ab"), Just (0, "group", "1-g-a")),
+ ((0, "alice", "5-pq-ab"), Just (0, "direct", "2-p-ab")),
+ ((0, "alice", "6-pq-ab"), Just (1, "direct", "3-p-ba"))
+ ]
+ )
+ where
+ mapChat = map (\(a, b, _) -> (a, b)) . chat'''
+
+testGroupDirectFilesXFTP :: HasCallStack => FilePath -> IO ()
+testGroupDirectFilesXFTP =
+ testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ withXFTPServer $ do
+ createGroup3 "team" alice bob cath
+ threadDelay 1000000
+
+ alice `send` "/f #team @bob ./tests/fixtures/test.pdf"
+ alice <# "/f #team @bob (direct) ./tests/fixtures/test.pdf"
+ alice <## "use /fc 1 to cancel sending"
+ bob <# "#team alice (direct)> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+ alice <## "completed uploading file 1 (test.pdf) for #team @bob (direct)"
+
+ bob ##> "/fr 1 ./tests/tmp"
+ bob
+ <### [ "saving file 1 from alice to ./tests/tmp/test.pdf",
+ "started receiving file 1 (test.pdf) from alice"
+ ]
+ bob <## "completed receiving file 1 (test.pdf) from alice"
+
+ src <- B.readFile "./tests/fixtures/test.pdf"
+ dest <- B.readFile "./tests/tmp/test.pdf"
+ dest `shouldBe` src
+
+ cath / 50000
+ cath ##> "/fr 1 ./tests/tmp"
+ cath <##. "chat db error: SEUserNotFoundByFileId"
+
+ alice `send` "/f #team @cath ./tests/fixtures/test.jpg"
+ alice <# "/f #team @cath (direct) ./tests/fixtures/test.jpg"
+ alice <## "use /fc 2 to cancel sending"
+ cath <# "#team alice (direct)> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ cath <## "use /fr 1 [/ | ] to receive it"
+ alice <## "completed uploading file 2 (test.jpg) for #team @cath (direct)"
+
+ cath ##> "/fr 1 ./tests/tmp"
+ cath
+ <### [ "saving file 1 from alice to ./tests/tmp/test.jpg",
+ "started receiving file 1 (test.jpg) from alice"
+ ]
+ cath <## "completed receiving file 1 (test.jpg) from alice"
+
+ src2 <- B.readFile "./tests/fixtures/test.jpg"
+ dest2 <- B.readFile "./tests/tmp/test.jpg"
+ dest2 `shouldBe` src2
+
+ bob / 50000
+
+ alice #$> ("/_get chat #1 count=2", mapChat, [((1, "bob", ""), Just "./tests/fixtures/test.pdf"), ((1, "cath", ""), Just "./tests/fixtures/test.jpg")])
+ bob #$> ("/_get chat #1 count=2", mapChat, [((0, "", "connected"), Nothing), ((0, "alice", ""), Just "./tests/tmp/test.pdf")])
+ cath #$> ("/_get chat #1 count=2", mapChat, [((0, "", "connected"), Nothing), ((0, "alice", ""), Just "./tests/tmp/test.jpg")])
+ where
+ cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
+ mapChat = map (\(a, _, c) -> (a, c)) . chat'''
+
+testGroupDirectFilesSMP :: HasCallStack => FilePath -> IO ()
+testGroupDirectFilesSMP =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+ threadDelay 1000000
+
+ alice `send` "/f #team @bob ./tests/fixtures/test.pdf"
+ alice <# "/f #team @bob (direct) ./tests/fixtures/test.pdf"
+ alice <## "use /fc 1 to cancel sending"
+ bob <# "#team alice (direct)> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+ bob ##> "/fr 1 ./tests/tmp"
+ bob <## "saving file 1 from alice to ./tests/tmp/test.pdf"
+ concurrently_
+ (alice <## "started sending file 1 (test.pdf) to bob")
+ (bob <## "started receiving file 1 (test.pdf) from alice")
+ concurrently_
+ (alice <## "completed sending file 1 (test.pdf) to bob")
+ (bob <## "completed receiving file 1 (test.pdf) from alice")
+
+ src <- B.readFile "./tests/fixtures/test.pdf"
+ dest <- B.readFile "./tests/tmp/test.pdf"
+ dest `shouldBe` src
+
+ cath / 50000
+ cath ##> "/fr 1 ./tests/tmp"
+ cath <##. "chat db error: SEUserNotFoundByFileId"
+
+ alice `send` "/f #team @cath ./tests/fixtures/test.jpg"
+ alice <# "/f #team @cath (direct) ./tests/fixtures/test.jpg"
+ alice <## "use /fc 2 to cancel sending"
+ cath <# "#team alice (direct)> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ cath <## "use /fr 1 [/ | ] to receive it"
+ cath ##> "/fr 1 ./tests/tmp"
+ cath <## "saving file 1 from alice to ./tests/tmp/test.jpg"
+ concurrently_
+ (alice <## "started sending file 2 (test.jpg) to cath")
+ (cath <## "started receiving file 1 (test.jpg) from alice")
+ concurrently_
+ (alice <## "completed sending file 2 (test.jpg) to cath")
+ (cath <## "completed receiving file 1 (test.jpg) from alice")
+
+ src2 <- B.readFile "./tests/fixtures/test.jpg"
+ dest2 <- B.readFile "./tests/tmp/test.jpg"
+ dest2 `shouldBe` src2
+
+ bob / 50000
+
+ alice #$> ("/_get chat #1 count=2", mapChat, [((1, "bob", ""), Just "./tests/fixtures/test.pdf"), ((1, "cath", ""), Just "./tests/fixtures/test.jpg")])
+ bob #$> ("/_get chat #1 count=2", mapChat, [((0, "", "connected"), Nothing), ((0, "alice", ""), Just "./tests/tmp/test.pdf")])
+ cath #$> ("/_get chat #1 count=2", mapChat, [((0, "", "connected"), Nothing), ((0, "alice", ""), Just "./tests/tmp/test.jpg")])
+ where
+ mapChat = map (\(a, _, c) -> (a, c)) . chat'''
+
+testGroupDirectCancelFileXFTP :: HasCallStack => FilePath -> IO ()
+testGroupDirectCancelFileXFTP =
+ testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ withXFTPServer $ do
+ createGroup3 "team" alice bob cath
+
+ alice `send` "/f #team @bob ./tests/fixtures/test.pdf"
+ alice <# "/f #team @bob (direct) ./tests/fixtures/test.pdf"
+ alice <## "use /fc 1 to cancel sending"
+ bob <# "#team alice (direct)> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+ alice <## "completed uploading file 1 (test.pdf) for #team @bob (direct)"
+
+ cath / 50000
+
+ alice ##> "/fc 1"
+ alice <## "cancelled sending file 1 (test.pdf) to bob"
+ bob <## "alice cancelled sending file 1 (test.pdf)"
+
+ cath / 50000
+
+ bob ##> "/fr 1 ./tests/tmp"
+ bob <## "file cancelled: test.pdf"
+
+ cath ##> "/fr 1 ./tests/tmp"
+ cath <##. "chat db error: SEUserNotFoundByFileId"
+ where
+ cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
+
+testGroupDirectQuotesFiles :: HasCallStack => FilePath -> IO ()
+testGroupDirectQuotesFiles =
+ testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ withXFTPServer $ do
+ createGroup3 "team" alice bob cath
+ threadDelay 1000000
+
+ bob `send` "#team @alice hi alice"
+ bob <# "#team @alice (direct) hi alice"
+ alice <# "#team bob (direct)> hi alice"
+ threadDelay 1000000
+
+ msgItemId1 <- lastItemId alice
+ alice ##> ("/_send #1 @2 json {\"filePath\": \"./tests/fixtures/test.pdf\", \"quotedItemId\": " <> msgItemId1 <> ", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}")
+ alice <# "#team @bob (direct) > bob hi alice"
+ alice <## " hey bob"
+ alice <# "/f #team @bob (direct) ./tests/fixtures/test.pdf"
+ alice <## "use /fc 1 to cancel sending"
+ bob <# "#team alice (direct)> > bob hi alice"
+ bob <## " hey bob"
+ bob <# "#team alice (direct)> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+ alice <## "completed uploading file 1 (test.pdf) for #team @bob (direct)"
+
+ bob ##> "/fr 1 ./tests/tmp"
+ bob
+ <### [ "saving file 1 from alice to ./tests/tmp/test.pdf",
+ "started receiving file 1 (test.pdf) from alice"
+ ]
+ bob <## "completed receiving file 1 (test.pdf) from alice"
+
+ src <- B.readFile "./tests/fixtures/test.pdf"
+ dest <- B.readFile "./tests/tmp/test.pdf"
+ dest `shouldBe` src
+
+ cath / 50000
+ cath ##> "/fr 1 ./tests/tmp"
+ cath <##. "chat db error: SEUserNotFoundByFileId"
+
+ alice
+ #$> ( "/_get chat #1 count=2",
+ chat''',
+ [ ((0, "bob", "hi alice"), Nothing, Nothing),
+ ((1, "bob", "hey bob"), Just (0, "direct", "hi alice"), Just "./tests/fixtures/test.pdf")
+ ]
+ )
+ bob
+ #$> ( "/_get chat #1 count=2",
+ chat''',
+ [ ((1, "alice", "hi alice"), Nothing, Nothing),
+ ((0, "alice", "hey bob"), Just (1, "direct", "hi alice"), Just "./tests/tmp/test.pdf")
+ ]
+ )
+ cath #$> ("/_get chat #1 count=1", chat''', [((0, "", "connected"), Nothing, Nothing)])
+ where
+ cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp"}
+
+testGroupDirectUpdate :: HasCallStack => FilePath -> IO ()
+testGroupDirectUpdate =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice `send` "#team @bob hi bob"
+ alice <# "#team @bob (direct) hi bob"
+ bob <# "#team alice (direct)> hi bob"
+
+ msgItemId1 <- lastItemId alice
+ alice ##> ("/_update item #1 " <> msgItemId1 <> " text hey 👋")
+ alice <# "#team @bob (direct) [edited] hey 👋"
+ bob <# "#team alice (direct)> [edited] hey 👋"
+ cath / 50000
+
+ alice ##> "! #team (hey 👋) hello there"
+ alice <# "#team @bob (direct) [edited] hello there"
+ bob <# "#team alice (direct)> [edited] hello there"
+ cath / 50000
+
+testGroupDirectDelete :: HasCallStack => FilePath -> IO ()
+testGroupDirectDelete =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice `send` "#team @bob hi bob"
+ alice <# "#team @bob (direct) hi bob"
+ bob <# "#team alice (direct)> hi bob"
+
+ msgItemId1 <- lastItemId alice
+ alice #$> ("/_delete item #1 " <> msgItemId1 <> " broadcast", id, "message marked deleted")
+ bob <# "#team alice (direct)> [marked deleted] hi bob"
+ cath / 50000
+
+testGroupDirectLiveMessage :: HasCallStack => FilePath -> IO ()
+testGroupDirectLiveMessage =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice `send` "/live #team @bob hello"
+ msgItemId1 <- lastItemId alice
+ bob <#. "#team alice (direct)> [LIVE started]"
+ alice ##> ("/_update item #1 " <> msgItemId1 <> " text hello there")
+ alice <# "#team @bob (direct) [LIVE] hello there"
+ bob <# "#team alice (direct)> [LIVE ended] hello there"
+ cath / 50000
+
+testGroupDirectReactions :: HasCallStack => FilePath -> IO ()
+testGroupDirectReactions =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice `send` "#team @bob hi bob"
+ alice <# "#team @bob (direct) hi bob"
+ bob <# "#team alice (direct)> hi bob"
+
+ bob ##> "+1 #team hi"
+ bob <## "added 👍"
+ alice <# "#team bob (direct)> > alice hi bob"
+ alice <## " + 👍"
+ cath / 50000
+
+ alice ##> "+^ #team hi"
+ alice <## "added 🚀"
+ bob <# "#team alice (direct)> > alice hi bob"
+ bob <## " + 🚀"
+ cath / 50000
+
+testGroupDirectProhibitPreference :: HasCallStack => FilePath -> IO ()
+testGroupDirectProhibitPreference =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ createGroup3' "team" alice bob cath GRMember
+
+ alice ##> "/set direct #team off"
+ alice <## "updated group preferences:"
+ alice <## "Direct messages: off"
+ directProhibited bob
+ directProhibited cath
+
+ bob ##> "#team @cath hi cath"
+ bob <## "bad chat command: direct messages not allowed"
+
+ cath ##> "#team @bob hi cath"
+ cath <## "bad chat command: direct messages not allowed"
+
+ alice ##> "/mr team bob admin"
+ alice <## "#team: you changed the role of bob from member to admin"
+ concurrentlyN_
+ [ bob <## "#team: alice changed your role from member to admin",
+ cath <## "#team: alice changed the role of bob from member to admin"
+ ]
+
+ -- admin can send & can send to admin
+
+ bob `send` "#team @cath hi cath, as admin"
+ bob <# "#team @cath (direct) hi cath, as admin"
+ cath <# "#team bob (direct)> hi cath, as admin"
+
+ cath `send` "#team @bob hi bob, to admin"
+ cath <# "#team @bob (direct) hi bob, to admin"
+ bob <# "#team cath (direct)> hi bob, to admin"
+ where
+ directProhibited :: HasCallStack => TestCC -> IO ()
+ directProhibited cc = do
+ cc <## "alice updated group #team:"
+ cc <## "updated group preferences:"
+ cc <## "Direct messages: off"
+
+testGroupDirectProhibitNotSupported :: HasCallStack => FilePath -> IO ()
+testGroupDirectProhibitNotSupported tmp =
+ withNewTestChat tmp "alice" aliceProfile $ \alice -> do
+ withNewTestChat tmp "bob" bobProfile $ \bob -> do
+ withNewTestChatCfg tmp testCfg {chatVRange = mkVersionRange 1 1} "cath" cathProfile $ \cath -> do
+ createGroup3 "team" alice bob cath
+
+ bob ##> "#team @cath hi cath"
+ bob <## "peer chat protocol version range incompatible"
diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs
index c120d661ff..05075f5b5f 100644
--- a/tests/ChatTests/Utils.hs
+++ b/tests/ChatTests/Utils.hs
@@ -181,7 +181,12 @@ chatF :: String -> [((Int, String), Maybe String)]
chatF = map (\(a, _, c) -> (a, c)) . chat''
chat'' :: String -> [((Int, String), Maybe (Int, String), Maybe String)]
-chat'' = read
+chat'' = map (\(a, b, c) -> (mapNoDirect a, mapNoDirect <$> b, c)) . chat'''
+ where
+ mapNoDirect (a1, _, a3) = (a1, a3)
+
+chat''' :: String -> [((Int, String, String), Maybe (Int, String, String), Maybe String)]
+chat''' = read
chatFeatures :: [(Int, String)]
chatFeatures = map (\(a, _, _) -> a) chatFeatures''
@@ -456,27 +461,33 @@ showName (TestCC ChatController {currentUser} _ _ _ _ _) = do
pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName
createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO ()
-createGroup2 gName cc1 cc2 = do
+createGroup2 gName cc1 cc2 = createGroup2' gName cc1 cc2 GRAdmin
+
+createGroup2' :: HasCallStack => String -> TestCC -> TestCC -> GroupMemberRole -> IO ()
+createGroup2' gName cc1 cc2 memberRole = do
connectUsers cc1 cc2
name2 <- userName cc2
cc1 ##> ("/g " <> gName)
cc1 <## ("group #" <> gName <> " is created")
cc1 <## ("to add members use /a " <> gName <> " or /create link #" <> gName)
- addMember gName cc1 cc2 GRAdmin
+ addMember gName cc1 cc2 memberRole
cc2 ##> ("/j " <> gName)
concurrently_
(cc1 <## ("#" <> gName <> ": " <> name2 <> " joined the group"))
(cc2 <## ("#" <> gName <> ": you joined the group"))
createGroup3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO ()
-createGroup3 gName cc1 cc2 cc3 = do
- createGroup2 gName cc1 cc2
+createGroup3 gName cc1 cc2 cc3 = createGroup3' gName cc1 cc2 cc3 GRAdmin
+
+createGroup3' :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> GroupMemberRole -> IO ()
+createGroup3' gName cc1 cc2 cc3 memberRole = do
+ createGroup2' gName cc1 cc2 memberRole
connectUsers cc1 cc3
name1 <- userName cc1
name3 <- userName cc3
sName2 <- showName cc2
sName3 <- showName cc3
- addMember gName cc1 cc3 GRAdmin
+ addMember gName cc1 cc3 memberRole
cc3 ##> ("/j " <> gName)
concurrentlyN_
[ cc1 <## ("#" <> gName <> ": " <> name3 <> " joined the group"),
diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs
index 3acc78e7d8..f9421f6914 100644
--- a/tests/ProtocolTests.hs
+++ b/tests/ProtocolTests.hs
@@ -57,7 +57,7 @@ testConnReq = CRInvitationUri connReqData testE2ERatchetParams
quotedMsg :: QuotedMsg
quotedMsg =
QuotedMsg
- (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing)
+ (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing Nothing)
$ MCText "hello there!"
(==##) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation
@@ -105,10 +105,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
#==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))
it "x.msg.new simple text - timed message TTL" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}"
- #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing))
+ #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing Nothing))
it "x.msg.new simple text - live message" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}"
- #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True)))
+ #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True) Nothing))
+ it "x.msg.new simple text - direct message scope" $
+ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"scope\":\"direct\"}}"
+ #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing Nothing (Just MSDirect)))
it "x.msg.new simple link" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing))
@@ -130,27 +133,41 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
chatInitialVRange
(Just $ SharedMsgId "\1\2\3\4")
(XMsgNew (MCQuote quotedMsg (extMsgContent (MCText "hello to you too") Nothing)))
+ it "x.msg.new quote - direct referenced message scope" $
+ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\",\"msgScope\":\"direct\"}}}}"
+ ##==## ChatMessage
+ chatInitialVRange
+ (Just $ SharedMsgId "\1\2\3\4")
+ ( XMsgNew
+ ( MCQuote
+ ( QuotedMsg
+ (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing (Just MSDirect))
+ $ MCText "hello there!"
+ )
+ (extMsgContent (MCText "hello to you too") Nothing)
+ )
+ )
it "x.msg.new quote - timed message TTL" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"ttl\":3600}}"
##==## ChatMessage
chatInitialVRange
(Just $ SharedMsgId "\1\2\3\4")
- (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing)))
+ (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing Nothing)))
it "x.msg.new quote - live message" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}"
##==## ChatMessage
chatInitialVRange
(Just $ SharedMsgId "\1\2\3\4")
- (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True))))
+ (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True) Nothing)))
it "x.msg.new forward" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing))
it "x.msg.new forward - timed message TTL" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}"
- ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing))
+ ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing Nothing))
it "x.msg.new forward - live message" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}"
- ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True)))
+ ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True) Nothing))
it "x.msg.new simple text with file" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing})))
diff --git a/website/src/.well-known/apple-app-site-association/index.json b/website/src/.well-known/apple-app-site-association/index.json
index 956e006f25..3cd3fdd043 100644
--- a/website/src/.well-known/apple-app-site-association/index.json
+++ b/website/src/.well-known/apple-app-site-association/index.json
@@ -3,7 +3,7 @@
"details": [
{
"appIDs": [
- "5NN7GUYB6.chat.simplex.app"
+ "5NN7GUYB6T.chat.simplex.app"
],
"components": [
{
diff --git a/website/src/.well-known/nostr.json b/website/src/.well-known/nostr.json
new file mode 100644
index 0000000000..8c409fb86c
--- /dev/null
+++ b/website/src/.well-known/nostr.json
@@ -0,0 +1,18 @@
+{
+ "names": {
+ "_": "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171"
+ },
+ "relays": {
+ "c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171": [
+ "wss://nostr.orangepill.dev",
+ "wss://eden.nostr.land",
+ "wss://relay.damus.io",
+ "wss://relay.snort.social",
+ "wss://relay.current.fyi",
+ "wss://nos.lol",
+ "wss://relay.nostr.bg",
+ "wss://nostr-verified.wellorder.net",
+ "wss://nostr.milou.lol"
+ ]
+ }
+}
diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html
index d6b2372c3e..1c23806760 100644
--- a/website/src/_includes/footer.html
+++ b/website/src/_includes/footer.html
@@ -63,7 +63,7 @@
-
+