Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f94c6f98a | |||
| 164426db49 | |||
| 307db450d8 | |||
| e6233722db | |||
| d26083d8b7 | |||
| f561698fb9 | |||
| 74966b1425 | |||
| 51b8ce10ae | |||
| 8c716962fb | |||
| fee2b247e9 | |||
| 992ba75306 | |||
| 70168967a3 | |||
| 3221c0abb5 | |||
| d8049d4bfc | |||
| b15d39eb4a | |||
| 85e36ac12c | |||
| 5e67654249 | |||
| 404b7093b7 | |||
| cad1ad87a8 | |||
| fd27839442 | |||
| ae6fae5ced | |||
| e9cddd6ca3 | |||
| 76bde53206 | |||
| 0a2f7681d8 | |||
| 3776e1c29c | |||
| 2e4ffb7fe9 | |||
| 954338658f | |||
| 7f78b08100 | |||
| 5d8d636adc | |||
| aac80dacf7 | |||
| e43be1ad8b | |||
| 57000fa3f0 | |||
| db367b376b | |||
| cc498572cd | |||
| 622ab549a3 | |||
| f896c4453d | |||
| 38f65c82c3 | |||
| 22733f505d | |||
| 26a019d9d2 | |||
| 7531791f1b | |||
| cd28ba62a1 | |||
| 32dff4e1a3 | |||
| fd85026a0d | |||
| e3f63db5ab | |||
| 7f959103c1 |
@@ -81,7 +81,7 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Selected updates:
|
||||
|
||||
[Jul 23, 2022. v3.1-beta: access via Tor SOCKS5 proxy in terminal app, join and leave chat groups in mobile apps, up to 90x reduced battery and traffic usage, docker configurations for self-hosted servers](./blog/20220723-simplex-chat-v3.1-tor-groups-efficiency.md)
|
||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced netwrok settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
|
||||
|
||||
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
|
||||
|
||||
@@ -107,12 +107,6 @@ The channel through which you share the link does not have to be secure - it is
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash
|
||||
```
|
||||
|
||||
or to install v3.1.0-beta.0 that supports accessing SimpleX servers via SOCKS5 proxy:
|
||||
|
||||
```
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash -s -- v3.1.0-beta.0
|
||||
```
|
||||
|
||||
Once the chat client is installed, simply run `simplex-chat` from your terminal.
|
||||
|
||||

|
||||
@@ -129,7 +123,9 @@ Unlike federated networks, the server nodes **do not have records of the users**
|
||||
|
||||
Only the client devices have information about users, their contacts and groups.
|
||||
|
||||
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
|
||||
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
|
||||
|
||||
## Privacy: technical details and limitations
|
||||
|
||||
@@ -139,20 +135,20 @@ What is already implemented:
|
||||
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each bi-directional conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of content padding to the fixed size - server blocks inside TLS and message content inside each of 3 encrypted envelopes are padded to a constant size, to prevent any correlation by message size.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
5. Several levels of content padding to frustrate message size attacks.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
|
||||
We plan to add soon:
|
||||
|
||||
1. Access to messaging servers via Tor. Currently it is supported only for [terminal CLI clients](./docs/CLI.md); mobile clients access servers via public Internet, and the servers can observe IP addresses of the clients. Depending which platform you use, you might be able to configure access via Tor independently. The servers provided by SimpleX Chat do not correlate users by or log IP addresses, and you can use your own servers, but in some scenarios that may be not a sufficient level of privacy.
|
||||
2. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
3. Local database encryption. Currently the local chat database stored on your device is not encrypted.
|
||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
5. Independent implementation audit.
|
||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local database encryption. Currently the local chat database stored on your device is not encrypted.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
4. Independent implementation audit.
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -179,8 +175,9 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
|
||||
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
|
||||
- ✅ Chat database export and import
|
||||
- 🏗 Chat groups in mobile apps (in progress).
|
||||
- 🏗 Connecting to messaging servers via Tor (in progress).
|
||||
- ✅ Chat groups in mobile apps.
|
||||
- ✅ Connecting to messaging servers via Tor.
|
||||
- 🏗 Dual server addresses to access messaging servers as v3 hidden services (in progress).
|
||||
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
|
||||
- Chat database encryption.
|
||||
- Disappearing messages, with mutual agreement.
|
||||
@@ -211,7 +208,7 @@ It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- [Monero wallet](monero:8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt): 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
|
||||
Thank you,
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/uiDesigner.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 48
|
||||
versionName "3.1"
|
||||
versionCode 49
|
||||
versionName "3.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
@@ -26,9 +26,19 @@ android {
|
||||
cppFlags ''
|
||||
}
|
||||
}
|
||||
manifestPlaceholders.app_name = "@string/app_name"
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
|
||||
manifestPlaceholders.extract_native_libs = compression_level != "0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix "$application_id_suffix"
|
||||
debuggable new Boolean("$enable_debuggable")
|
||||
manifestPlaceholders.app_name = "$app_name"
|
||||
// Provider can't be the same for different apps on the same device
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
@@ -64,6 +74,7 @@ android {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,3 +124,70 @@ dependencies {
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
|
||||
def buildType = "unknown"
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.doLast {
|
||||
buildType = "debug"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.doLast {
|
||||
buildType = "release"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||
def keyAlias = ""
|
||||
def keyPassword = ""
|
||||
def storeFile = ""
|
||||
def storePassword = ""
|
||||
if (project.properties['android.injected.signing.key.alias'] != null) {
|
||||
keyAlias = project.properties['android.injected.signing.key.alias']
|
||||
keyPassword = project.properties['android.injected.signing.key.password']
|
||||
storeFile = project.properties['android.injected.signing.store.file']
|
||||
storePassword = project.properties['android.injected.signing.store.password']
|
||||
} else if (android.signingConfigs.hasProperty(buildType)) {
|
||||
def gradleConfig = android.signingConfigs[buildType]
|
||||
keyAlias = gradleConfig.keyAlias
|
||||
keyPassword = gradleConfig.keyPassword
|
||||
storeFile = gradleConfig.storeFile
|
||||
storePassword = gradleConfig.storePassword
|
||||
} else {
|
||||
// There is no signing config for current build type, can't sign the apk
|
||||
println("No signing configs for this build type: $buildType")
|
||||
return
|
||||
}
|
||||
|
||||
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
|
||||
|
||||
exec {
|
||||
workingDir '../../../scripts/android'
|
||||
setEnvironment(['JAVA_HOME': "$javaHome"])
|
||||
commandLine './compress-and-sign-apk.sh', \
|
||||
"$compression_level", \
|
||||
"$outputDir", \
|
||||
"$sdkDir", \
|
||||
"$storeFile", \
|
||||
"$storePassword", \
|
||||
"$keyAlias", \
|
||||
"$keyPassword"
|
||||
}
|
||||
|
||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
}
|
||||
|
||||
// View all gradle properties set
|
||||
// project.properties.each { k, v -> println "$k -> $v" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="@string/app_name"
|
||||
android:label="${app_name}"
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:label="${app_name}"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<intent-filter>
|
||||
@@ -66,9 +67,7 @@
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivity_default"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/icon"
|
||||
android:enabled="true"
|
||||
android:targetActivity=".MainActivity">
|
||||
@@ -80,9 +79,7 @@
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivity_dark_blue"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/icon_dark_blue"
|
||||
android:enabled="false"
|
||||
android:targetActivity=".MainActivity">
|
||||
@@ -98,7 +95,7 @@
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="chat.simplex.app.provider"
|
||||
android:authorities="${provider_authorities}"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
||||
@@ -70,7 +70,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
@@ -131,24 +131,6 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private fun schedulePeriodicServiceRestartWorker() {
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
|
||||
@@ -4,14 +4,17 @@ import android.app.Application
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.getFilesDirectory
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
@@ -57,8 +60,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
chatController.startChat(user)
|
||||
SimplexService.start(applicationContext)
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,11 +81,39 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
|
||||
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
|
||||
appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext)
|
||||
}
|
||||
|
||||
/*
|
||||
* It takes 1-10 milliseconds to process this function. Better to do it in a background thread
|
||||
* */
|
||||
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
|
||||
if (!allowToStartServiceAfterAppExit()) {
|
||||
return@launch
|
||||
}
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var context: SimplexApp private set
|
||||
|
||||
init {
|
||||
val socketName = "local.socket.address.listen.native.cmd2"
|
||||
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
@@ -54,7 +55,10 @@ class SimplexService: Service() {
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
stopService()
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
|
||||
|
||||
// If private notifications are enabled and battery optimization is disabled, restart the service
|
||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -147,6 +151,11 @@ class SimplexService: Service() {
|
||||
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
// If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
|
||||
return
|
||||
}
|
||||
|
||||
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
|
||||
it.setPackage(packageName)
|
||||
};
|
||||
@@ -162,6 +171,17 @@ class SimplexService: Service() {
|
||||
Log.d(TAG, "StartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
companion object {
|
||||
fun toggleReceiver(enable: Boolean) {
|
||||
Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
|
||||
val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
component,
|
||||
if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restart on destruction
|
||||
|
||||
@@ -211,27 +211,39 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun markChatItemsRead(cInfo: ChatInfo) {
|
||||
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
|
||||
val markedRead = markItemsReadInCurrentChat(cInfo, range)
|
||||
// update preview
|
||||
val chatIdx = getChatIndex(cInfo.id)
|
||||
if (chatIdx >= 0) {
|
||||
val chat = chats[chatIdx]
|
||||
val lastId = chat.chatItems.lastOrNull()?.id
|
||||
if (lastId != null) {
|
||||
chats[chatIdx] = chat.copy(chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = lastId + 1))
|
||||
chats[chatIdx] = chat.copy(
|
||||
chatStats = chat.chatStats.copy(
|
||||
unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
|
||||
// Can't use minUnreadItemId currently since chat items can have unread items between read items
|
||||
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// update current chat
|
||||
}
|
||||
|
||||
private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int {
|
||||
var markedRead = 0
|
||||
if (chatId.value == cInfo.id) {
|
||||
var i = 0
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew) {
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
chatItems[i] = item.withStatus(CIStatus.RcvRead())
|
||||
markedRead++
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return markedRead
|
||||
}
|
||||
|
||||
// func popChat(_ id: String) {
|
||||
@@ -248,6 +260,22 @@ class ChatModel(val controller: ChatController) {
|
||||
fun removeChat(id: String) {
|
||||
chats.removeAll { it.id == id }
|
||||
}
|
||||
|
||||
fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
|
||||
// update current chat
|
||||
return if (chatId.value == groupInfo.id) {
|
||||
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
|
||||
if (memberIndex >= 0) {
|
||||
groupMembers[memberIndex] = member
|
||||
false
|
||||
} else {
|
||||
groupMembers.add(member)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChatType(val type: String) {
|
||||
@@ -432,6 +460,7 @@ class Contact(
|
||||
val profile: Profile,
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
// val chatSettings: ChatSettings,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -512,6 +541,7 @@ class GroupInfo (
|
||||
override val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
val membership: GroupMember,
|
||||
// val chatSettings: ChatSettings,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.app.ActivityManager
|
||||
import android.app.ActivityManager.RunningAppProcessInfo
|
||||
import android.app.Application
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
@@ -314,8 +315,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(type: ChatType, id: Long): Chat? {
|
||||
val r = sendCmd(CC.ApiGetChat(type, id))
|
||||
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
|
||||
val r = sendCmd(CC.ApiGetChat(type, id, pagination, search))
|
||||
if (r is CR.ApiChat ) return r.chat
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
@@ -599,10 +600,11 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole) {
|
||||
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
|
||||
val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole))
|
||||
if (r is CR.SentGroupInvitation) return
|
||||
if (r is CR.SentGroupInvitation) return r.member
|
||||
Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiJoinGroup(groupId: Long) {
|
||||
@@ -756,12 +758,22 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
|
||||
// TODO NtfManager.shared.notifyGroupInvitation
|
||||
}
|
||||
is CR.JoinedGroupMemberConnecting ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.DeletedMemberUser -> // TODO update user member
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.DeletedMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.deletedMember)
|
||||
is CR.LeftMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.GroupDeleted -> // TODO update user member
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.UserJoinedGroup ->
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.GroupDeleted ->
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.DeletedMemberUser ->
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.JoinedGroupMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.ConnectedToGroupMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.GroupUpdated ->
|
||||
chatModel.updateGroup(r.toGroup)
|
||||
is CR.RcvFileStart ->
|
||||
@@ -879,7 +891,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
fun showBackgroundServiceNoticeIfNeeded() {
|
||||
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||
if (!appPrefs.backgroundServiceNoticeShown.get()) {
|
||||
// the branch for the new users who has never seen service notice
|
||||
// the branch for the new users who have never seen service notice
|
||||
if (isIgnoringBatteryOptimizations(appContext)) {
|
||||
showBGServiceNotice()
|
||||
} else {
|
||||
@@ -892,15 +904,19 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
// the branch for users who have app installed, and have seen the service notice,
|
||||
// but the battery optimization for the app is on (Android 12) AND the service is running
|
||||
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
|
||||
showDisablingServiceNotice()
|
||||
appPrefs.runServiceInBackground.set(false)
|
||||
chatModel.runServiceInBackground.value = false
|
||||
SimplexService.StartReceiver.toggleReceiver(false)
|
||||
} else {
|
||||
// show battery optimization notice
|
||||
showBGServiceNoticeIgnoreOptimization()
|
||||
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||
}
|
||||
} else {
|
||||
// service is allowed and battery optimization is disabled
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,7 +1120,7 @@ sealed class CC {
|
||||
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiDeleteStorage: CC()
|
||||
class ApiGetChats: CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
|
||||
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
@@ -1120,6 +1136,7 @@ sealed class CC {
|
||||
class SetUserSMPServers(val smpServers: List<String>): CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
class APIGetNetworkConfig: CC()
|
||||
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
|
||||
class APIContactInfo(val contactId: Long): CC()
|
||||
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class AddContact: CC()
|
||||
@@ -1155,7 +1172,7 @@ sealed class CC {
|
||||
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||
is ApiDeleteStorage -> "/_db delete"
|
||||
is ApiGetChats -> "/_get chats pcc=on"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
@@ -1170,6 +1187,7 @@ sealed class CC {
|
||||
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
is APIGetNetworkConfig -> "/network"
|
||||
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
|
||||
is APIContactInfo -> "/_info @$contactId"
|
||||
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
|
||||
is AddContact -> "/connect"
|
||||
@@ -1221,6 +1239,7 @@ sealed class CC {
|
||||
is SetUserSMPServers -> "setUserSMPServers"
|
||||
is APISetNetworkConfig -> "/apiSetNetworkConfig"
|
||||
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
|
||||
is APISetChatSettings -> "/apiSetChatSettings"
|
||||
is APIContactInfo -> "apiContactInfo"
|
||||
is APIGroupMemberInfo -> "apiGroupMemberInfo"
|
||||
is AddContact -> "addContact"
|
||||
@@ -1255,6 +1274,24 @@ sealed class CC {
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ChatPagination {
|
||||
class Last(val count: Int): ChatPagination()
|
||||
class After(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Before(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is Last -> "count=${this.count}"
|
||||
is After -> "after=${this.chatItemId} count=${this.count}"
|
||||
is Before -> "before=${this.chatItemId} count=${this.count}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INITIAL_COUNT = 100
|
||||
const val PRELOAD_COUNT = 100
|
||||
const val UNTIL_PRELOAD_COUNT = 50
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
|
||||
|
||||
@@ -1264,6 +1301,8 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? =
|
||||
@Serializable
|
||||
data class NetCfg(
|
||||
val socksProxy: String? = null,
|
||||
val hostMode: HostMode = HostMode.OnionViaSocks,
|
||||
val requiredHostMode: Boolean = false,
|
||||
val tcpConnectTimeout: Long, // microseconds
|
||||
val tcpTimeout: Long, // microseconds
|
||||
val tcpKeepAlive: KeepAliveOpts?,
|
||||
@@ -1293,6 +1332,13 @@ data class NetCfg(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class HostMode {
|
||||
@SerialName("onionViaSocks") OnionViaSocks,
|
||||
@SerialName("onion") Onion,
|
||||
@SerialName("public") Public;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KeepAliveOpts(
|
||||
val keepIdle: Int, // seconds
|
||||
@@ -1305,9 +1351,15 @@ data class KeepAliveOpts(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatSettings(
|
||||
val enableNtfs: Boolean
|
||||
)
|
||||
|
||||
val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1378,7 +1430,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("contactsList") class ContactsList(val contacts: List<Contact>): CR()
|
||||
// group events
|
||||
@Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact): CR()
|
||||
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR()
|
||||
@@ -1391,11 +1443,11 @@ sealed class CR {
|
||||
@Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR()
|
||||
@Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused
|
||||
@Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused
|
||||
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
|
||||
// receiving file events
|
||||
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
|
||||
@@ -1547,7 +1599,7 @@ sealed class CR {
|
||||
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
|
||||
is ContactsList -> json.encodeToString(contacts)
|
||||
is GroupCreated -> json.encodeToString(groupInfo)
|
||||
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact"
|
||||
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member"
|
||||
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
|
||||
is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is LeftMemberUser -> json.encodeToString(groupInfo)
|
||||
|
||||
@@ -15,8 +15,8 @@ val DarkGray = Color(43, 44, 46, 255)
|
||||
val HighOrLowlight = Color(139, 135, 134, 255)
|
||||
val MessagePreviewDark = Color(179, 175, 174, 255)
|
||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 20)
|
||||
val ToolbarDark = Color(80, 80, 80, 20)
|
||||
val ToolbarLight = Color(220, 220, 220, 12)
|
||||
val ToolbarDark = Color(80, 80, 80, 12)
|
||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
||||
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
||||
val GroupDark = Color(80, 80, 80, 60)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
@@ -19,34 +20,35 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chat.group.AddGroupMembersView
|
||||
import chat.simplex.app.views.chat.group.GroupChatInfoView
|
||||
import chat.simplex.app.views.chat.item.ChatItemView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.chatlist.populateGroupMembers
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.AppBarHeight
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
||||
val searchText = remember { mutableStateOf("") }
|
||||
val user = chatModel.currentUser.value
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
|
||||
@@ -54,29 +56,24 @@ fun ChatView(chatModel: ChatModel) {
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (chat == null || user == null) {
|
||||
if (activeChat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val chat = activeChat!!
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
// TODO a more advanced version would mark as read only if in view
|
||||
LaunchedEffect(chat.chatItems) {
|
||||
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
|
||||
delay(750L)
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
}
|
||||
|
||||
// We need to have real unreadCount value for displaying it inside top right button
|
||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||
val unreadCount = remember {
|
||||
derivedStateOf {
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
ChatLayout(
|
||||
user,
|
||||
chat,
|
||||
unreadCount,
|
||||
composeState,
|
||||
composeView = {
|
||||
if (chat.chatInfo.sendMsgEnabled) {
|
||||
@@ -90,6 +87,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = {
|
||||
@@ -106,7 +104,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
populateGroupMembers(cInfo.groupInfo, chatModel)
|
||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
@@ -122,7 +120,22 @@ fun ChatView(chatModel: ChatModel) {
|
||||
val c = chatModel.chats.firstOrNull {
|
||||
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
||||
}
|
||||
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
|
||||
if (c != null) {
|
||||
withApi {
|
||||
openChat(c.chatInfo, chatModel)
|
||||
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
|
||||
activeChat = c
|
||||
}
|
||||
}
|
||||
},
|
||||
loadPrevMessages = { cInfo ->
|
||||
val c = chatModel.getChat(cInfo.id)
|
||||
val firstId = chatModel.chatItems.firstOrNull()?.id
|
||||
if (c != null && firstId != null) {
|
||||
withApi {
|
||||
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
@@ -160,7 +173,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
},
|
||||
addMembers = { groupInfo ->
|
||||
withApi {
|
||||
populateGroupMembers(groupInfo, chatModel)
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
@@ -170,6 +183,25 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
markRead = { range, unreadCountAfter ->
|
||||
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
range
|
||||
)
|
||||
}
|
||||
},
|
||||
onSearchValueChanged = { value ->
|
||||
if (searchText.value == value) return@ChatLayout
|
||||
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
|
||||
withApi {
|
||||
apiFindMessages(c.chatInfo, chatModel, value)
|
||||
searchText.value = value
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -179,22 +211,27 @@ fun ChatView(chatModel: ChatModel) {
|
||||
fun ChatLayout(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
composeView: (@Composable () -> Unit),
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
scope: CoroutineScope,
|
||||
attachmentBottomSheetState: ModalBottomSheetState,
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
Modifier
|
||||
@@ -214,13 +251,25 @@ fun ChatLayout(
|
||||
sheetState = attachmentBottomSheetState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) },
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) },
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = floatingButton.value,
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, joinGroup, acceptCall)
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
BoxWithConstraints(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(
|
||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, floatingButton
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,54 +283,79 @@ fun ChatInfoToolbar(
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
|
||||
IconButton(onClick, modifier = modifier) {
|
||||
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
val onBackClicked = {
|
||||
if (!showSearch) {
|
||||
back()
|
||||
} else {
|
||||
onSearchValueChanged("")
|
||||
showSearch = false
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||
startCall(CallMediaType.Audio)
|
||||
}
|
||||
}
|
||||
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||
startCall(CallMediaType.Video)
|
||||
}
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
if (cInfo.groupInfo.canAddMembers) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
toolbarButton(Icons.Outlined.PersonAdd, R.string.icon_descr_add_members) {
|
||||
addMembers(cInfo.groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 80.dp).fillMaxWidth()
|
||||
.clickable(onClick = info),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ChatInfoToolbarTitle(cInfo)
|
||||
BackHandler(onBack = onBackClicked)
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
|
||||
showMenu = false
|
||||
showSearch = true
|
||||
})
|
||||
}
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
startCall(CallMediaType.Audio)
|
||||
}) {
|
||||
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
|
||||
showMenu = false
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
addMembers(chat.chatInfo.groupInfo)
|
||||
}) {
|
||||
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
barButtons.add {
|
||||
IconButton({ showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { NavigationButtonBack(onBackClicked) },
|
||||
title = { ChatInfoToolbarTitle(chat.chatInfo) },
|
||||
onTitleClick = info,
|
||||
showSearch = showSearch,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = barButtons
|
||||
)
|
||||
|
||||
Divider(Modifier.padding(top = AppBarHeight))
|
||||
|
||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
Modifier.widthIn(min = 220.dp)
|
||||
) {
|
||||
menuItems.forEach { it() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,37 +397,87 @@ val CIListStateSaver = run {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemsList(
|
||||
fun BoxWithConstraintsScope.ChatItemsList(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
acceptCall: (Contact) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
floatingButton: MutableState<@Composable () -> Unit>
|
||||
) {
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
|
||||
val keyboardState by getKeyboardState()
|
||||
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
|
||||
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val cxt = LocalContext.current
|
||||
LazyColumn(state = listState) {
|
||||
itemsIndexed(chatItems) { i, cItem ->
|
||||
if (i == 0) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||
// and prevents scrolling to bottom on orientation change
|
||||
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) {
|
||||
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
// Don't autoscroll next time until it will be needed
|
||||
shouldAutoScroll = false
|
||||
}
|
||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||
// Scroll to bottom when search value changes from something to nothing and back
|
||||
LaunchedEffect(searchValue.value.isEmpty()) {
|
||||
// They are equal when orientation was changed, don't need to scroll.
|
||||
// LaunchedEffect unaware of this event since it uses remember, not rememberSaveable
|
||||
if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect
|
||||
prevSearchEmptiness = searchValue.value.isEmpty()
|
||||
|
||||
if (listState.firstVisibleItemIndex != 0) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
}
|
||||
|
||||
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c ->
|
||||
loadPrevMessages(c.chatInfo)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
|
||||
val directions = setOf(DismissDirection.EndToStart)
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = directions,
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
|
||||
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
|
||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i > 0) chatItems[i - 1] else null
|
||||
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
@@ -362,7 +486,11 @@ fun ChatItemsList(
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { openDirectChat(contactId) }
|
||||
.clickable {
|
||||
openDirectChat(contactId)
|
||||
// Scroll to first unread message when direct chat will be loaded
|
||||
shouldAutoScroll = true
|
||||
}
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
@@ -374,7 +502,7 @@ fun ChatItemsList(
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
@@ -384,20 +512,148 @@ fun ChatItemsList(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
)
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
val len = chatItems.count()
|
||||
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
|
||||
scope.launch {
|
||||
ciListState.value = CIListState(true, len, keyboardState)
|
||||
listState.animateScrollToItem(len - 1)
|
||||
|
||||
if (cItem.isRcvNew) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
scope.launch {
|
||||
delay(750)
|
||||
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, floatingButton, listState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxWithConstraintsScope.FloatingButtons(
|
||||
chatItems: List<ChatItem>,
|
||||
unreadCount: State<Int>,
|
||||
minUnreadItemId: Long,
|
||||
searchValue: State<String>,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
floatingButton: MutableState<@Composable () -> Unit>,
|
||||
listState: LazyListState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
|
||||
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
|
||||
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
firstVisibleIndex = it
|
||||
firstItemIsVisible = firstVisibleIndex == 0
|
||||
}
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
lastIndexOfVisibleItems = it
|
||||
}
|
||||
}
|
||||
|
||||
val bottomUnreadCount by remember {
|
||||
derivedStateOf {
|
||||
if (unreadCount.value == 0) return@derivedStateOf 0
|
||||
|
||||
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
|
||||
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
|
||||
|
||||
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
|
||||
}
|
||||
}
|
||||
|
||||
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
|
||||
|
||||
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
|
||||
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
|
||||
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
|
||||
floatingButton.value = bottomEndFloatingButton(
|
||||
bottomUnreadCount,
|
||||
showButtonWithCounter,
|
||||
showButtonWithArrow,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
|
||||
}
|
||||
)
|
||||
}
|
||||
// Don't show top FAB if is in search
|
||||
if (searchValue.value.isNotEmpty()) return
|
||||
val fabSize = 56.dp
|
||||
val topUnreadCount by remember {
|
||||
derivedStateOf { unreadCount.value - bottomUnreadCount }
|
||||
}
|
||||
val showButtonWithCounter = topUnreadCount > 0
|
||||
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
|
||||
TopEndFloatingButton(
|
||||
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
|
||||
topUnreadCount,
|
||||
showButtonWithCounter,
|
||||
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||
onLongClick = { showDropDown = true }
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
Modifier.width(220.dp),
|
||||
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
markRead(
|
||||
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||
bottomUnreadCount
|
||||
)
|
||||
showDropDown = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.mark_read),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreloadItems(
|
||||
listState: LazyListState,
|
||||
remaining: Int = 10,
|
||||
chat: Chat,
|
||||
items: List<*>,
|
||||
onLoadMore: (chat: Chat) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(listState, chat, items) {
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.map {
|
||||
val totalItemsNumber = it.totalItemsCount
|
||||
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
|
||||
totalItemsNumber
|
||||
else
|
||||
0
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.filter { it > 0 }
|
||||
.collect {
|
||||
onLoadMore(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
|
||||
@@ -410,6 +666,88 @@ fun MemberImage(member: GroupMember) {
|
||||
ProfileImage(38.dp, member.memberProfile.image)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopEndFloatingButton(
|
||||
modifier: Modifier = Modifier,
|
||||
unreadCount: Int,
|
||||
showButtonWithCounter: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) = when {
|
||||
showButtonWithCounter -> {
|
||||
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
|
||||
FloatingActionButton(
|
||||
{}, // no action here
|
||||
modifier.size(48.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomEndFloatingButton(
|
||||
unreadCount: Int,
|
||||
showButtonWithCounter: Boolean,
|
||||
showButtonWithArrow: Boolean,
|
||||
onClickArrowDown: () -> Unit,
|
||||
onClickCounter: () -> Unit
|
||||
): @Composable () -> Unit = when {
|
||||
showButtonWithCounter -> {
|
||||
{
|
||||
FloatingActionButton(
|
||||
onClick = onClickCounter,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
showButtonWithArrow -> {
|
||||
{
|
||||
FloatingActionButton(
|
||||
onClick = onClickArrowDown,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
{}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ViewConfiguration.bigTouchSlop(slop: Float = 80f) = object: ViewConfiguration {
|
||||
override val longPressTimeoutMillis
|
||||
get() =
|
||||
this@bigTouchSlop.longPressTimeoutMillis
|
||||
override val doubleTapTimeoutMillis
|
||||
get() =
|
||||
this@bigTouchSlop.doubleTapTimeoutMillis
|
||||
override val doubleTapMinTimeMillis
|
||||
get() =
|
||||
this@bigTouchSlop.doubleTapMinTimeMillis
|
||||
override val touchSlop: Float get() = slop
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -437,6 +775,8 @@ fun PreviewChatLayout() {
|
||||
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||
val searchValue = remember { mutableStateOf("") }
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
@@ -444,22 +784,27 @@ fun PreviewChatLayout() {
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
unreadCount = unreadCount,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
addMembers = { _ -> }
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -486,6 +831,8 @@ fun PreviewGroupChatLayout() {
|
||||
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||
val searchValue = remember { mutableStateOf("") }
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
@@ -493,22 +840,27 @@ fun PreviewGroupChatLayout() {
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
unreadCount = unreadCount,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
addMembers = { _ -> }
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -26,7 +28,9 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
@@ -35,6 +39,16 @@ fun SendMsgView(
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
val cs = composeState.value
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
|
||||
// In replying state
|
||||
focusRequester.requestFocus()
|
||||
delay(50)
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = cs.message,
|
||||
onValueChange = onMessageChange,
|
||||
@@ -44,7 +58,7 @@ fun SendMsgView(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
autoCorrect = true
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
|
||||
@@ -41,7 +41,10 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
|
||||
inviteMembers = {
|
||||
withApi {
|
||||
selectedContacts.forEach {
|
||||
chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
|
||||
val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
|
||||
if (member != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.chatlist.populateGroupMembers
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
@@ -43,7 +43,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
developerTools,
|
||||
addMembers = {
|
||||
withApi {
|
||||
populateGroupMembers(groupInfo, chatModel)
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
|
||||
@@ -37,19 +37,22 @@ fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: Co
|
||||
member,
|
||||
connStats,
|
||||
developerTools,
|
||||
removeMember = { removeMemberDialog(member, chatModel, close) }
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMemberDialog(member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.button_remove_member),
|
||||
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
|
||||
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
|
||||
if (removedMember != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, removedMember)
|
||||
}
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
|
||||
@@ -96,7 +96,19 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun populateGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
|
||||
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||
}
|
||||
|
||||
suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) {
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||
}
|
||||
|
||||
suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
|
||||
chatModel.groupMembers.clear()
|
||||
chatModel.groupMembers.addAll(groupMembers)
|
||||
@@ -247,12 +259,16 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
||||
}
|
||||
|
||||
fun markChatRead(chat: Chat, chatModel: ChatModel) {
|
||||
// Just to be sure
|
||||
if (chat.chatStats.unreadCount == 0) return
|
||||
|
||||
val minUnreadItemId = chat.chatStats.minUnreadItemId
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -18,10 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.MakeConnection
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
@@ -72,6 +68,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
|
||||
}
|
||||
BottomSheetScaffold(
|
||||
topBar = { ChatListToolbar(scaffoldCtrl, stopped) },
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
@@ -84,8 +81,6 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ChatListToolbar(scaffoldCtrl, stopped)
|
||||
Divider()
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel)
|
||||
} else {
|
||||
@@ -106,49 +101,42 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Menu,
|
||||
stringResource(R.string.icon_descr_settings),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(5.dp)
|
||||
)
|
||||
if (!stopped) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.AddCircle,
|
||||
stringResource(R.string.add_contact),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
},
|
||||
onTitleClick = null,
|
||||
showSearch = false,
|
||||
onSearchValueChanged = {},
|
||||
buttons = listOf{
|
||||
if (!stopped) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.AddCircle,
|
||||
stringResource(R.string.add_contact),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication),
|
||||
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -140,7 +140,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
|
||||
unreadCountStr(n),
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
@@ -163,6 +163,11 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun unreadCountStr(n: Int): String {
|
||||
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStatusImage(chat: Chat) {
|
||||
val s = chat.serverInfo.networkStatus
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import chat.simplex.app.R
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIos
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
|
||||
@Composable
|
||||
fun DefaultTopAppBar(
|
||||
navigationButton: @Composable RowScope.() -> Unit,
|
||||
title: @Composable () -> Unit,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
showSearch: Boolean,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
|
||||
) {
|
||||
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
|
||||
val modifier = if (!showSearch) {
|
||||
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
|
||||
} else Modifier
|
||||
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (!showSearch) {
|
||||
title()
|
||||
} else {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||
navigationIcon = navigationButton,
|
||||
buttons = if (!showSearch) buttons else emptyList(),
|
||||
centered = !showSearch
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
||||
IconButton(onButtonClicked) {
|
||||
Icon(
|
||||
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
||||
IconButton(onClick = onButtonClicked) {
|
||||
Icon(
|
||||
Icons.Outlined.Menu,
|
||||
stringResource(R.string.icon_descr_settings),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
|
||||
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
|
||||
backgroundColor: Color = MaterialTheme.colors.primarySurface,
|
||||
centered: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(AppBarHeight)
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (navigationIcon != null) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(TitleInsetWithIcon - AppBarHorizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = navigationIcon
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
buttons.forEach { it() }
|
||||
}
|
||||
val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
|
||||
val endPadding = (buttons.size * 50f).dp
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
|
||||
end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
title()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val AppBarHeight = 56.dp
|
||||
private val AppBarHorizontalPadding = 4.dp
|
||||
private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding
|
||||
private val TitleInsetWithIcon = 72.dp
|
||||
@@ -16,7 +16,13 @@
|
||||
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.interaction.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
@@ -31,19 +37,19 @@ import androidx.compose.ui.input.pointer.consumeAllChanges
|
||||
import androidx.compose.ui.input.pointer.consumeDownChange
|
||||
import androidx.compose.ui.input.pointer.isOutOfBounds
|
||||
import androidx.compose.ui.input.pointer.positionChangeConsumed
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.util.fastAll
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import chat.simplex.app.TAG
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
/**
|
||||
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
|
||||
* */
|
||||
|
||||
interface PressGestureScope : Density {
|
||||
interface PressGestureScope: Density {
|
||||
suspend fun tryAwaitRelease(): Boolean
|
||||
}
|
||||
|
||||
@@ -67,7 +73,6 @@ suspend fun PointerInputScope.detectGesture(
|
||||
if (onPress !== NoPressGesture) launch {
|
||||
pressScope.onPress(down.position)
|
||||
}
|
||||
|
||||
val longPressTimeout = onLongPress?.let {
|
||||
viewConfiguration.longPressTimeoutMillis
|
||||
} ?: (Long.MAX_VALUE / 2)
|
||||
@@ -81,7 +86,6 @@ suspend fun PointerInputScope.detectGesture(
|
||||
} else {
|
||||
if (shouldConsume)
|
||||
upOrCancel.consumeDownChange()
|
||||
|
||||
// If onLongPress event is needed, cancel short press event
|
||||
if (onLongPress != null)
|
||||
pressScope.cancel()
|
||||
@@ -138,7 +142,6 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
|
||||
return null
|
||||
@@ -148,7 +151,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
|
||||
|
||||
private class PressGestureScopeImpl(
|
||||
density: Density
|
||||
) : PressGestureScope, Density by density {
|
||||
): PressGestureScope, Density by density {
|
||||
private var isReleased = false
|
||||
private var isCanceled = false
|
||||
private val mutex = Mutex(locked = false)
|
||||
@@ -176,3 +179,49 @@ private class PressGestureScopeImpl(
|
||||
return isCanceled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing.
|
||||
* Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]).
|
||||
* Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable]
|
||||
* */
|
||||
@Composable
|
||||
fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
var topLevelInteraction: Interaction? by remember { mutableStateOf(null) }
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
topLevelInteraction = interaction
|
||||
}
|
||||
}
|
||||
LaunchedEffect(topLevelInteraction is PressInteraction.Press) {
|
||||
if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect
|
||||
try {
|
||||
withTimeout(longPressTimeoutMillis) {
|
||||
while (isActive) {
|
||||
delay(10)
|
||||
when (topLevelInteraction) {
|
||||
is PressInteraction.Press -> {}
|
||||
is PressInteraction.Release -> {
|
||||
onClick(); break
|
||||
}
|
||||
is PressInteraction.Cancel -> break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
// Long click happened
|
||||
onLongClick()
|
||||
} catch (ex: CancellationException) {
|
||||
// Canceled coroutine + PressInteraction.Release == short click
|
||||
if (topLevelInteraction is PressInteraction.Release)
|
||||
onClick()
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
} catch (ex: Exception) {
|
||||
// Should never be called
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
return interactionSource
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Modifier.badgeLayout() =
|
||||
layout { measurable, constraints ->
|
||||
@@ -15,3 +23,22 @@ fun Modifier.badgeLayout() =
|
||||
placeable.place((width - placeable.width) / 2, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwipeToDismissModifier(
|
||||
state: DismissState,
|
||||
directions: Set<DismissDirection> = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd),
|
||||
swipeDistance: Float,
|
||||
): Modifier {
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val anchors = mutableMapOf(0f to DismissValue.Default)
|
||||
if (DismissDirection.StartToEnd in directions) anchors += swipeDistance to DismissValue.DismissedToEnd
|
||||
if (DismissDirection.EndToStart in directions) anchors += -swipeDistance to DismissValue.DismissedToStart
|
||||
return Modifier.swipeable(
|
||||
state = state,
|
||||
anchors = anchors,
|
||||
thresholds = { _, _ -> FractionalThreshold(0.5f) },
|
||||
orientation = Orientation.Horizontal,
|
||||
reverseDirection = isRtl,
|
||||
).offset { IntOffset(state.offset.value.roundToInt(), 0) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = searchText,
|
||||
modifier = modifier
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.focusRequester(focusRequester)
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
searchText = it
|
||||
onValueChange(it)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = searchText,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = {
|
||||
Text(placeholder)
|
||||
},
|
||||
trailingIcon = if (searchText.isNotEmpty()) {{
|
||||
IconButton({ searchText = ""; onValueChange("") }) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||
}
|
||||
}} else null,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.chat.group.AddGroupMembersView
|
||||
import chat.simplex.app.views.chatlist.populateGroupMembers
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
@@ -41,7 +41,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf()))
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = groupInfo.id
|
||||
populateGroupMembers(groupInfo, chatModel)
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
close.invoke()
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
|
||||
@@ -23,7 +23,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
@@ -44,6 +44,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
chatModel.runServiceInBackground.value = on
|
||||
SimplexService.StartReceiver.toggleReceiver(on)
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> команда</string>
|
||||
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
|
||||
<string name="icon_descr_email">Email</string>
|
||||
<string name="icon_descr_more_button">Больше</string>
|
||||
|
||||
<!-- Add Contact - AddContactView.kt -->
|
||||
<string name="invalid_QR_code">Неверный QR код</string>
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> Team</string>
|
||||
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> Logo</string>
|
||||
<string name="icon_descr_email">Email</string>
|
||||
<string name="icon_descr_more_button">More</string>
|
||||
|
||||
<!-- Add Contact - AddContactView.kt -->
|
||||
<string name="invalid_QR_code">Invalid QR code</string>
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
buildscript {
|
||||
Properties localProperties = new Properties()
|
||||
if (rootProject.file('local.properties').canRead()) {
|
||||
localProperties.load(rootProject.file("local.properties").newDataInputStream())
|
||||
}
|
||||
|
||||
ext {
|
||||
compose_version = '1.2.0-beta02'
|
||||
compose_version = localProperties['compose_version'] ?: '1.2.0-beta02'
|
||||
kotlin_version = localProperties['kotlin_version'] ?: '1.6.21'
|
||||
gradle_plugin_version = localProperties['gradle_plugin_version'] ?: '7.2.0'
|
||||
|
||||
// Name that will be shown for debug build. By default it is from strings
|
||||
app_name = localProperties['app_name'] ?: "@string/app_name"
|
||||
// Whether the app is debuggable or not. Specify `false` if you want good performance in debug builds
|
||||
enable_debuggable = localProperties['debuggable'] ?: true
|
||||
// Ending part of package name.
|
||||
// Provide, for example, `application_id_suffix=.debug` in local.properties
|
||||
// to allow debug & release versions to coexist
|
||||
application_id_suffix = localProperties['application_id_suffix'] ?: ''
|
||||
|
||||
// Compression level for debug AND release apk. 0 = disable compression. Max is 9
|
||||
compression_level = localProperties['compression_level'] ?: '0'
|
||||
|
||||
// NOTE: If you need a different version of something, provide it in `local.properties`
|
||||
// like so: compose_version=123, or gradle_plugin_version=1.2.3, etc
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.2.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21"
|
||||
classpath "com.android.tools.build:gradle:$gradle_plugin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
@@ -16,10 +38,10 @@ buildscript {
|
||||
}
|
||||
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '7.2.0' apply false
|
||||
id 'com.android.library' version '7.2.0' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.6.21' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21'
|
||||
id 'com.android.application' version "$gradle_plugin_version" apply false
|
||||
id 'com.android.library' version "$gradle_plugin_version" apply false
|
||||
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
|
||||
@@ -22,8 +22,9 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chats: [Chat] = []
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var chatItems: [ChatItem] = []
|
||||
@Published var reversedChatItems: [ChatItem] = []
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GroupMember] = []
|
||||
// items in the terminal view
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: String?
|
||||
@@ -158,17 +159,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// add to current chat
|
||||
if chatId == cInfo.id {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if self.chatId == cInfo.id {
|
||||
Task {
|
||||
await apiMarkChatItemRead(cInfo, cItem)
|
||||
NtfManager.shared.decNtfBadgeCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withAnimation { reversedChatItems.insert(cItem, at: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,13 +177,14 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
withAnimation(.default) {
|
||||
self.chatItems[i] = cItem
|
||||
self.reversedChatItems[i] = cItem
|
||||
self.reversedChatItems[i].viewTimestamp = .now
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
withAnimation { reversedChatItems.insert(cItem, at: 0) }
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -209,12 +201,12 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if chatItems[i].isRcvNew() == true {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if reversedChatItems[i].isRcvNew() == true {
|
||||
NtfManager.shared.decNtfBadgeCount()
|
||||
}
|
||||
_ = withAnimation {
|
||||
self.chatItems.remove(at: i)
|
||||
self.reversedChatItems.remove(at: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,13 +220,44 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
var i = 0
|
||||
while i < chatItems.count {
|
||||
if case .rcvNew = chatItems[i].meta.itemStatus {
|
||||
chatItems[i].meta.itemStatus = .rcvRead
|
||||
}
|
||||
i = i + 1
|
||||
markCurrentChatRead()
|
||||
}
|
||||
}
|
||||
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < reversedChatItems.count {
|
||||
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
markCurrentChatRead(fromIndex: i)
|
||||
if let chat = getChat(cInfo.id) {
|
||||
var unreadBelow = 0
|
||||
var j = i - 1
|
||||
while j >= 0 {
|
||||
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
|
||||
unreadBelow += 1
|
||||
}
|
||||
j -= 1
|
||||
}
|
||||
// update preview
|
||||
let markedCount = chat.chatStats.unreadCount - unreadBelow
|
||||
if markedCount > 0 {
|
||||
NtfManager.shared.decNtfBadgeCount(by: markedCount)
|
||||
chat.chatStats.unreadCount -= markedCount
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
markChatItemsRead(cInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +271,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// clear current chat
|
||||
if chatId == cInfo.id {
|
||||
chatItems = []
|
||||
reversedChatItems = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +281,9 @@ final class ChatModel: ObservableObject {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
chatItems[j].meta.itemStatus = .rcvRead
|
||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,8 +292,8 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 {
|
||||
return chatItems[i - 1]
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 {
|
||||
return reversedChatItems[i + 1]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
@@ -291,6 +315,51 @@ final class ChatModel: ObservableObject {
|
||||
chats.removeAll(where: { $0.id == id })
|
||||
}
|
||||
}
|
||||
|
||||
func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool {
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
|
||||
withAnimation(.default) {
|
||||
self.groupMembers[i] = member
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { groupMembers.append(member) }
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
var unreadBelow = 0
|
||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
||||
totalBelow += 1
|
||||
if reversedChatItems[i].isRcvNew() {
|
||||
unreadBelow += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
|
||||
}
|
||||
|
||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||
let maxIx = reversedChatItems.count - 1
|
||||
var i = 0
|
||||
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
|
||||
while i < maxIx && !inView(i) { i += 1 }
|
||||
while i < maxIx && inView(i) { i += 1 }
|
||||
return reversedChatItems[min(i - 1, maxIx)]
|
||||
}
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts {
|
||||
var totalBelow: Int
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
final class Chat: ObservableObject, Identifiable {
|
||||
|
||||
@@ -200,7 +200,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
|
||||
func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
addNotification(createMessageReceivedNtf(cInfo, cItem))
|
||||
if cInfo.ntfsEnabled {
|
||||
addNotification(createMessageReceivedNtf(cInfo, cItem))
|
||||
}
|
||||
}
|
||||
|
||||
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
|
||||
|
||||
@@ -185,19 +185,25 @@ func apiGetChats() throws -> [ChatData] {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id))
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
|
||||
if case let .apiChat(chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat) {
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
|
||||
if case let .apiChat(chat) = r { return chat.chatItems }
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "") {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
let m = ChatModel.shared
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
m.chatItems = chat.chatItems
|
||||
m.reversedChatItems = chat.chatItems.reversed()
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
@@ -302,6 +308,10 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
|
||||
func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? {
|
||||
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
|
||||
if case let .contactInfo(_, connStats) = r { return connStats }
|
||||
@@ -534,13 +544,13 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||
}
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat) async {
|
||||
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
|
||||
do {
|
||||
let minItemId = chat.chatStats.minUnreadItemId
|
||||
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
|
||||
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) }
|
||||
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
} catch {
|
||||
logger.error("markChatRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
@@ -548,10 +558,11 @@ func markChatRead(_ chat: Chat) async {
|
||||
|
||||
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
} catch {
|
||||
logger.error("markChatItemRead apiChatRead error: \(responseError(error))")
|
||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,17 +578,9 @@ func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo {
|
||||
throw r
|
||||
}
|
||||
|
||||
func addMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async {
|
||||
do {
|
||||
try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)
|
||||
} catch let error {
|
||||
logger.error("addMember error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async throws {
|
||||
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
|
||||
let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
|
||||
if case .sentGroupInvitation = r { return }
|
||||
if case let .sentGroupInvitation(_, _, member) = r { return member }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -597,7 +600,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
|
||||
}
|
||||
}
|
||||
|
||||
func apiRemoveMember(groupId: Int64, memberId: Int64) async throws -> GroupMember {
|
||||
func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember {
|
||||
let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false)
|
||||
if case let .userDeletedMember(_, member) = r { return member }
|
||||
throw r
|
||||
@@ -820,12 +823,22 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
chatItems: []
|
||||
))
|
||||
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
|
||||
case let .joinedGroupMemberConnecting(groupInfo, _, member):
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
case let .deletedMemberUser(groupInfo, _): // TODO update user member
|
||||
m.updateGroup(groupInfo)
|
||||
case let .deletedMember(groupInfo, _, deletedMember):
|
||||
_ = m.upsertGroupMember(groupInfo, deletedMember)
|
||||
case let .leftMember(groupInfo, member):
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
case let .groupDeleted(groupInfo, _): // TODO update user member
|
||||
m.updateGroup(groupInfo)
|
||||
case let .userJoinedGroup(groupInfo):
|
||||
m.updateGroup(groupInfo)
|
||||
case let .groupDeleted(groupInfo, _):
|
||||
m.updateGroup(groupInfo)
|
||||
case let .deletedMemberUser(groupInfo, _):
|
||||
m.updateGroup(groupInfo)
|
||||
case let .joinedGroupMember(groupInfo, member):
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
case let .connectedToGroupMember(groupInfo, member):
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
case let .groupUpdated(toGroup):
|
||||
m.updateGroup(toGroup)
|
||||
case let .rcvFileStart(aChatItem):
|
||||
|
||||
@@ -116,11 +116,6 @@ struct SimpleXApp: App {
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
loadChat(chat: chat)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if chatModel.chatId == chat.id {
|
||||
Task { await markChatRead(chat) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let chatId = chatModel.ntfContactRequest {
|
||||
chatModel.ntfContactRequest = nil
|
||||
|
||||
@@ -17,64 +17,31 @@ struct ChatView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var membersToAdd: [Contact] = []
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var tableView: UITableView?
|
||||
@State private var loadingItems = false
|
||||
@State private var firstPage = false
|
||||
@State private var itemsInView: Set<String> = []
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var searchMode = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
|
||||
var body: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
|
||||
return VStack {
|
||||
GeometryReader { g in
|
||||
let maxWidth =
|
||||
cInfo.chatType == .group
|
||||
? (g.size.width - 28) * 0.84 - 42
|
||||
: (g.size.width - 32) * 0.84
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.chatItems) { ci in
|
||||
if case let .groupRcv(member) = ci.chatDir {
|
||||
let prevItem = chatModel.getPrevChatItem(ci)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let showMember = prevItem == nil || showMemberImage(member, prevItem)
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
scrollToFirstUnread(proxy)
|
||||
}
|
||||
markAllRead()
|
||||
}
|
||||
.onChange(of: chatModel.chatItems.last?.id) { _ in
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture { hideKeyboard() }
|
||||
return VStack(spacing: 0) {
|
||||
if searchMode {
|
||||
searchToolbar()
|
||||
Divider()
|
||||
}
|
||||
ZStack(alignment: .trailing) {
|
||||
chatItemsList()
|
||||
if let proxy = scrollProxy {
|
||||
floatingButtons(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +52,23 @@ struct ChatView: View {
|
||||
composeState: $composeState,
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.disabled(!chat.chatInfo.sendMsgEnabled)
|
||||
.disabled(!cInfo.sendMsgEnabled)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { chatModel.chatId = nil } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.backward")
|
||||
Text("Chats", comment: "back button to return to chats list")
|
||||
Button {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if chatModel.chatId == nil {
|
||||
chatModel.reversedChatItems = []
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
@@ -110,8 +83,14 @@ struct ChatView: View {
|
||||
}
|
||||
await MainActor.run { showChatInfoSheet = true }
|
||||
}
|
||||
} else {
|
||||
showChatInfoSheet = true
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showChatInfoSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
@@ -132,21 +111,168 @@ struct ChatView: View {
|
||||
case let .direct(contact):
|
||||
HStack {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
callButton(contact, .video, imageName: "video")
|
||||
Menu {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
if groupInfo.canAddMembers {
|
||||
addMembersButton()
|
||||
.sheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo, membersToAdd: membersToAdd)
|
||||
}
|
||||
HStack {
|
||||
if groupInfo.canAddMembers {
|
||||
addMembersButton()
|
||||
.sheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
HStack {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
TextField("Search", text: $searchText)
|
||||
.focused($searchFocussed)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
|
||||
.foregroundColor(.secondary)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
Button ("Cancel") {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
searchFocussed = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
chatModel.reversedChatItems = []
|
||||
loadChat(chat: chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func chatItemsList() -> some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return GeometryReader { g in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
let maxWidth =
|
||||
cInfo.chatType == .group
|
||||
? (g.size.width - 28) * 0.84 - 42
|
||||
: (g.size.width - 32) * 0.84
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||
chatItemView(ci, maxWidth)
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
.onAppear {
|
||||
itemsInView.insert(ci.viewId)
|
||||
loadChatItems(cInfo, ci, proxy)
|
||||
if ci.isRcvNew() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
|
||||
Task {
|
||||
await apiMarkChatItemRead(cInfo, ci)
|
||||
NtfManager.shared.decNtfBadgeCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
itemsInView.remove(ci.viewId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
scrollProxy = proxy
|
||||
}
|
||||
.onTapGesture { hideKeyboard() }
|
||||
.onChange(of: searchText) { _ in
|
||||
loadChat(chat: chat, search: searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
}
|
||||
|
||||
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
|
||||
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
|
||||
return VStack {
|
||||
let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.onTapGesture { scrollUp(proxy) }
|
||||
.contextMenu {
|
||||
Button {
|
||||
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
|
||||
Task {
|
||||
await markChatRead(chat, aboveItem: ci)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if counts.unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(counts.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.onTapGesture { scrollToBottom(proxy) }
|
||||
} else if counts.totalBelow > 16 {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.onTapGesture { scrollToBottom(proxy) }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.frame(width: 44, height: 44)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
|
||||
@@ -157,13 +283,23 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func searchButton() -> some View {
|
||||
Button {
|
||||
searchMode = true
|
||||
searchFocussed = true
|
||||
searchText = ""
|
||||
} label: {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
Task {
|
||||
let ms = await apiListMembers(gInfo.apiId)
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
membersToAdd = filterMembersToAdd(ms)
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showAddMembersSheet = true
|
||||
}
|
||||
}
|
||||
@@ -173,76 +309,157 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
|
||||
.contextMenu {
|
||||
if ci.isMsgContent() {
|
||||
Button {
|
||||
withAnimation {
|
||||
if composeState.editing() {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
}
|
||||
}
|
||||
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
|
||||
Button {
|
||||
var shareItems: [Any] = [ci.content.text]
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
shareItems.append(image)
|
||||
}
|
||||
showShareSheet(items: shareItems)
|
||||
} label: { Label("Share", systemImage: "square.and.arrow.up") }
|
||||
Button {
|
||||
if case let .image(text, _) = ci.content.msgContent,
|
||||
text == "",
|
||||
let image = getLoadedImage(ci.file) {
|
||||
UIPasteboard.general.image = image
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
|
||||
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
|
||||
if loadingItems || firstPage { return }
|
||||
loadingItems = true
|
||||
Task {
|
||||
do {
|
||||
let items = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: .before(chatItemId: firstItem.id, count: 50),
|
||||
search: searchText
|
||||
)
|
||||
await MainActor.run {
|
||||
if items.count == 0 {
|
||||
firstPage = true
|
||||
} else {
|
||||
UIPasteboard.general.string = ci.content.text
|
||||
chatModel.reversedChatItems.append(contentsOf: items.reversed())
|
||||
}
|
||||
} label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = getLoadedImage(ci.file) {
|
||||
Button {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
} label: { Label("Save", systemImage: "square.and.arrow.down") }
|
||||
loadingItems = false
|
||||
}
|
||||
if ci.meta.editable {
|
||||
Button {
|
||||
withAnimation {
|
||||
composeState = ComposeState(editingItem: ci)
|
||||
}
|
||||
} label: { Label("Edit", systemImage: "square.and.pencil") }
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
} label: { Label("Delete", systemImage: "trash") }
|
||||
} else if ci.isDeletedContent() {
|
||||
Button(role: .destructive) {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
} label: { Label("Delete", systemImage: "trash") }
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
await MainActor.run { loadingItems = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir {
|
||||
let prevItem = chatModel.getPrevChatItem(ci)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let showMember = prevItem == nil || showMemberImage(member, prevItem)
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
var menu: [UIAction] = []
|
||||
if ci.isMsgContent() {
|
||||
menu.append(contentsOf: [
|
||||
UIAction(
|
||||
title: NSLocalizedString("Reply", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
if composeState.editing() {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
}
|
||||
}
|
||||
},
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.up")
|
||||
) { _ in
|
||||
var shareItems: [Any] = [ci.content.text]
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
shareItems.append(image)
|
||||
}
|
||||
showShareSheet(items: shareItems)
|
||||
},
|
||||
UIAction(
|
||||
title: NSLocalizedString("Copy", comment: "chat item action"),
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
if case let .image(text, _) = ci.content.msgContent,
|
||||
text == "",
|
||||
let image = getLoadedImage(ci.file) {
|
||||
UIPasteboard.general.image = image
|
||||
} else {
|
||||
UIPasteboard.general.string = ci.content.text
|
||||
}
|
||||
}
|
||||
])
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = getLoadedImage(ci.file) {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
if ci.meta.editable {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Edit", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.pencil")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
composeState = ComposeState(editingItem: ci)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
)
|
||||
} else if ci.isDeletedContent() {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
|
||||
.uiKitContextMenu(actions: menu)
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
}
|
||||
if let di = deletingItem {
|
||||
if di.meta.editable {
|
||||
Button("Delete for everyone",role: .destructive) {
|
||||
deleteMessage(.cidmBroadcast)
|
||||
}
|
||||
if let di = deletingItem, di.meta.editable {
|
||||
Button("Delete for everyone",role: .destructive) {
|
||||
deleteMessage(.cidmBroadcast)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
}
|
||||
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
@@ -251,34 +468,19 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
|
||||
withAnimation(animation) { scrollToBottom_(proxy) }
|
||||
}
|
||||
|
||||
func scrollToBottom_(_ proxy: ScrollViewProxy) {
|
||||
if let id = chatModel.chatItems.last?.id {
|
||||
proxy.scrollTo(id, anchor: .bottom)
|
||||
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
if let ci = chatModel.reversedChatItems.first {
|
||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||
}
|
||||
}
|
||||
|
||||
// align first unread with the top or the last unread with bottom
|
||||
func scrollToFirstUnread(_ proxy: ScrollViewProxy) {
|
||||
if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) {
|
||||
proxy.scrollTo(cItem.id)
|
||||
} else {
|
||||
scrollToBottom_(proxy)
|
||||
private func scrollUp(_ proxy: ScrollViewProxy) {
|
||||
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
|
||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||
}
|
||||
}
|
||||
|
||||
func markAllRead() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if chatModel.chatId == chat.id {
|
||||
Task { await markChatRead(chat) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessage(_ mode: CIDeleteMode) {
|
||||
private func deleteMessage(_ mode: CIDeleteMode) {
|
||||
logger.debug("ChatView deleteMessage")
|
||||
Task {
|
||||
logger.debug("ChatView deleteMessage: in Task")
|
||||
@@ -302,11 +504,45 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
|
||||
Button {
|
||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||
} label: {
|
||||
if chat.chatInfo.ntfsEnabled {
|
||||
Label("Mute", systemImage: "speaker.slash")
|
||||
} else {
|
||||
Label("Unmute", systemImage: "speaker.wave.2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let chatSettings = ChatSettings(enableNtfs: enableNtfs)
|
||||
try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings)
|
||||
await MainActor.run {
|
||||
switch chat.chatInfo {
|
||||
case var .direct(contact):
|
||||
contact.chatSettings = chatSettings
|
||||
ChatModel.shared.updateContact(contact)
|
||||
case var .group(groupInfo):
|
||||
groupInfo.chatSettings = chatSettings
|
||||
ChatModel.shared.updateGroup(groupInfo)
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiSetChatSettings error \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.chatId = "@1"
|
||||
chatModel.chatItems = [
|
||||
chatModel.reversedChatItems = [
|
||||
ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
ChatItem.getSample(2, .directRcv, .now, "hi"),
|
||||
ChatItem.getSample(3, .directRcv, .now, "hi there"),
|
||||
|
||||
@@ -14,7 +14,6 @@ struct AddGroupMembersView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
var membersToAdd: [Contact]
|
||||
var showSkip: Bool = false
|
||||
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
|
||||
@State private var selectedContacts = Set<Int64>()
|
||||
@@ -22,6 +21,8 @@ struct AddGroupMembersView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
|
||||
|
||||
let v = List {
|
||||
ChatInfoToolbar(chat: chat, imageSize: 48)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -81,11 +82,21 @@ struct AddGroupMembersView: View {
|
||||
func inviteMembersButton() -> some View {
|
||||
Button {
|
||||
Task {
|
||||
for contactId in selectedContacts {
|
||||
await addMember(groupId: chat.chatInfo.apiId, contactId: contactId, memberRole: selectedRole)
|
||||
do {
|
||||
for contactId in selectedContacts {
|
||||
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
|
||||
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
|
||||
}
|
||||
await MainActor.run { dismiss() }
|
||||
if let cb = addedMembersCb { cb(selectedContacts) }
|
||||
} catch {
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error adding member(s)"),
|
||||
message: Text(responseError(error))
|
||||
)
|
||||
)
|
||||
}
|
||||
await MainActor.run { dismiss() }
|
||||
if let cb = addedMembersCb { cb(selectedContacts) }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -132,6 +143,6 @@ struct AddGroupMembersView: View {
|
||||
|
||||
struct AddGroupMembersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData, membersToAdd: [])
|
||||
AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ struct GroupChatInfoView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var members: [GroupMember] = []
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
@@ -33,6 +32,10 @@ struct GroupChatInfoView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let members = chatModel.groupMembers
|
||||
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
|
||||
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
|
||||
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -57,7 +60,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo, membersToAdd: filterMembersToAdd(members))
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
.sheet(item: $selectedMember, onDismiss: { connectionStats = nil }) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: connectionStats)
|
||||
@@ -96,14 +99,6 @@ struct GroupChatInfoView: View {
|
||||
case .leaveGroupAlert: return leaveGroupAlert()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let ms = await apiListMembers(chat.chatInfo.apiId)
|
||||
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
|
||||
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
|
||||
await MainActor.run {
|
||||
members = ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupInfoHeader() -> some View {
|
||||
@@ -128,7 +123,13 @@ struct GroupChatInfoView: View {
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
showAddMembersSheet = true
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showAddMembersSheet = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
}
|
||||
|
||||
@@ -107,10 +107,13 @@ struct GroupMemberInfoView: View {
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
_ = try await apiRemoveMember(groupId: member.groupId, memberId: member.groupMemberId)
|
||||
dismiss()
|
||||
let member = try await apiRemoveMember(groupInfo.groupId, member.groupMemberId)
|
||||
await MainActor.run {
|
||||
_ = ChatModel.shared.upsertGroupMember(groupInfo, member)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("removeMemberAlert apiRemoveMember error: \(error.localizedDescription)")
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,15 +41,13 @@ struct ChatListNavLink: View {
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !contact.ready
|
||||
)
|
||||
.swipeActions(edge: .leading) {
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if chat.chatStats.unreadCount > 0 {
|
||||
markReadButton()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
clearChatButton()
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
AlertManager.shared.showAlert(
|
||||
contact.ready
|
||||
@@ -78,8 +76,6 @@ struct ChatListNavLink: View {
|
||||
.frame(height: 80)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
joinGroupButton()
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupChatButton(groupInfo)
|
||||
}
|
||||
@@ -104,15 +100,13 @@ struct ChatListNavLink: View {
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.frame(height: 80)
|
||||
.swipeActions(edge: .leading) {
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if chat.chatStats.unreadCount > 0 {
|
||||
markReadButton()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
clearChatButton()
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||
|
||||
@@ -36,7 +36,6 @@ struct ChatPreviewView: View {
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 8)
|
||||
@@ -108,13 +107,16 @@ struct ChatPreviewView: View {
|
||||
.padding(.trailing, 36)
|
||||
.padding(.bottom, 4)
|
||||
if unread > 0 {
|
||||
Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)")
|
||||
unreadCountText(unread)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Color.accentColor)
|
||||
.background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary)
|
||||
.cornerRadius(10)
|
||||
} else if !chat.chatInfo.ntfsEnabled {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -170,6 +172,10 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func unreadCountText(_ n: Int) -> Text {
|
||||
Text(n > 999 ? "\(n / 1000)k" : "\(n)")
|
||||
}
|
||||
|
||||
struct ChatPreviewView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// ContextMenu2.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 09/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View {
|
||||
self.overlay(Color(uiColor: .systemBackground))
|
||||
.overlay(
|
||||
InteractionView(content: self, menu: UIMenu(title: title, children: actions))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct InteractionConfig<Content: View> {
|
||||
let content: Content
|
||||
let menu: UIMenu
|
||||
}
|
||||
|
||||
private struct InteractionView<Content: View>: UIViewRepresentable {
|
||||
let content: Content
|
||||
let menu: UIMenu
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
let hostView = UIHostingController(rootView: content)
|
||||
hostView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let constraints = [
|
||||
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
|
||||
]
|
||||
view.addSubview(hostView.view)
|
||||
view.addConstraints(constraints)
|
||||
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
|
||||
view.addInteraction(menuInteraction)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
||||
let parent: InteractionView<Content>
|
||||
|
||||
init(_ parent: InteractionView<Content>) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
UIContextMenuConfiguration(
|
||||
identifier: nil,
|
||||
previewProvider: nil,
|
||||
actionProvider: { [weak self] _ in
|
||||
guard let self = self else { return nil }
|
||||
return self.parent.menu
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// func contextMenuInteraction(
|
||||
// _ interaction: UIContextMenuInteraction,
|
||||
// willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
|
||||
// animator: UIContextMenuInteractionCommitAnimating
|
||||
// ) {
|
||||
// animator.addCompletion {
|
||||
// print("user tapped")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ struct AddGroupView: View {
|
||||
if let chat = chat, let groupInfo = groupInfo {
|
||||
AddGroupMembersView(chat: chat,
|
||||
groupInfo: groupInfo,
|
||||
membersToAdd: filterMembersToAdd([]),
|
||||
showSkip: true) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.async {
|
||||
@@ -148,6 +147,12 @@ struct AddGroupView: View {
|
||||
hideKeyboard()
|
||||
do {
|
||||
let gInfo = try apiNewGroup(profile)
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||
m.addChat(c)
|
||||
withAnimation {
|
||||
|
||||
@@ -7,9 +7,27 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum OnionHostsAlert: Identifiable {
|
||||
case update(hosts: OnionHosts)
|
||||
case error(err: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .update(hosts): return "update \(hosts)"
|
||||
case let .error(err): return "error \(err)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkAndServers: View {
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var cfgLoaded = false
|
||||
@State private var currentNetCfg = NetCfg.defaults
|
||||
@State private var netCfg = NetCfg.defaults
|
||||
@State private var onionHosts: OnionHosts = .no
|
||||
@State private var showOnionHostsAlert: OnionHostsAlert?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -22,6 +40,10 @@ struct NetworkAndServers: View {
|
||||
settingsRow("server.rack") { Text("SMP servers") }
|
||||
}
|
||||
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
NavigationLink {
|
||||
AdvancedNetworkSettings()
|
||||
@@ -33,6 +55,69 @@ struct NetworkAndServers: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if cfgLoaded { return }
|
||||
cfgLoaded = true
|
||||
currentNetCfg = getNetCfg()
|
||||
resetNetCfgView()
|
||||
}
|
||||
.onChange(of: onionHosts) { _ in
|
||||
if onionHosts != OnionHosts(netCfg: currentNetCfg) {
|
||||
showOnionHostsAlert = .update(hosts: onionHosts)
|
||||
}
|
||||
}
|
||||
.alert(item: $showOnionHostsAlert) { a in
|
||||
switch a {
|
||||
case let .update(hosts):
|
||||
return Alert(
|
||||
title: Text("Update .onion hosts setting?"),
|
||||
message: Text(onionHostsInfo()) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
saveNetCfg(hosts)
|
||||
},
|
||||
secondaryButton: .cancel() {
|
||||
resetNetCfgView()
|
||||
}
|
||||
)
|
||||
case let .error(err):
|
||||
return Alert(
|
||||
title: Text("Error updating settings"),
|
||||
message: Text(err)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveNetCfg(_ hosts: OnionHosts) {
|
||||
do {
|
||||
let (hostMode, requiredHostMode) = hosts.hostMode
|
||||
netCfg.hostMode = hostMode
|
||||
netCfg.requiredHostMode = requiredHostMode
|
||||
let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults
|
||||
netCfg.tcpConnectTimeout = def.tcpConnectTimeout
|
||||
netCfg.tcpTimeout = def.tcpTimeout
|
||||
try setNetworkConfig(netCfg)
|
||||
currentNetCfg = netCfg
|
||||
setNetCfg(netCfg)
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
resetNetCfgView()
|
||||
showOnionHostsAlert = .error(err: err)
|
||||
logger.error("\(err)")
|
||||
}
|
||||
}
|
||||
|
||||
private func resetNetCfgView() {
|
||||
netCfg = currentNetCfg
|
||||
onionHosts = OnionHosts(netCfg: netCfg)
|
||||
}
|
||||
|
||||
private func onionHostsInfo() -> LocalizedStringKey {
|
||||
switch onionHosts {
|
||||
case .no: return "Onion hosts will not be used."
|
||||
case .prefer: return "Onion hosts will be used when available. Requires enabling VPN."
|
||||
case .require: return "Onion hosts will be required for connection. Requires enabling VPN."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
|
||||
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; };
|
||||
5C00165628B02AF40094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165128B02AF30094D739 /* libffi.a */; };
|
||||
5C00165728B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165228B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a */; };
|
||||
5C00165828B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165328B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a */; };
|
||||
5C00165928B02AF40094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165428B02AF30094D739 /* libgmp.a */; };
|
||||
5C00165A28B02AF40094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165528B02AF30094D739 /* libgmpxx.a */; };
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
|
||||
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
|
||||
@@ -52,11 +58,6 @@
|
||||
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
|
||||
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
|
||||
5C9C2D9F28929B6900CC63B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9A28929B6900CC63B1 /* libffi.a */; };
|
||||
5C9C2DA028929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */; };
|
||||
5C9C2DA128929B6900CC63B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9C28929B6900CC63B1 /* libgmp.a */; };
|
||||
5C9C2DA228929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */; };
|
||||
5C9C2DA328929B6900CC63B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */; };
|
||||
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */; };
|
||||
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */; };
|
||||
5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */; };
|
||||
@@ -194,6 +195,12 @@
|
||||
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
|
||||
5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = "<group>"; };
|
||||
5C00165128B02AF30094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C00165228B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C00165328B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a"; sourceTree = "<group>"; };
|
||||
5C00165428B02AF30094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C00165528B02AF30094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
|
||||
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
|
||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
|
||||
@@ -236,11 +243,6 @@
|
||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
|
||||
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
|
||||
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
|
||||
5C9C2D9A28929B6900CC63B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a"; sourceTree = "<group>"; };
|
||||
5C9C2D9C28929B6900CC63B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupProfileView.swift; sourceTree = "<group>"; };
|
||||
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedNetworkSettings.swift; sourceTree = "<group>"; };
|
||||
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAndServers.swift; sourceTree = "<group>"; };
|
||||
@@ -347,13 +349,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C9C2DA028929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a in Frameworks */,
|
||||
5C9C2DA128929B6900CC63B1 /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5C9C2DA228929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a in Frameworks */,
|
||||
5C9C2D9F28929B6900CC63B1 /* libffi.a in Frameworks */,
|
||||
5C00165728B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a in Frameworks */,
|
||||
5C00165828B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a in Frameworks */,
|
||||
5C00165628B02AF40094D739 /* libffi.a in Frameworks */,
|
||||
5C00165928B02AF40094D739 /* libgmp.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5C9C2DA328929B6900CC63B1 /* libgmpxx.a in Frameworks */,
|
||||
5C00165A28B02AF40094D739 /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -408,11 +410,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C9C2D9A28929B6900CC63B1 /* libffi.a */,
|
||||
5C9C2D9C28929B6900CC63B1 /* libgmp.a */,
|
||||
5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */,
|
||||
5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */,
|
||||
5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */,
|
||||
5C00165128B02AF30094D739 /* libffi.a */,
|
||||
5C00165428B02AF30094D739 /* libgmp.a */,
|
||||
5C00165528B02AF30094D739 /* libgmpxx.a */,
|
||||
5C00165228B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a */,
|
||||
5C00165328B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -452,6 +454,7 @@
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */,
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */,
|
||||
5C6BA666289BD954009B8ECC /* DismissSheets.swift */,
|
||||
5C00164328A26FBC0094D739 /* ContextMenu.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -785,6 +788,7 @@
|
||||
mainGroup = 5CA059BD279559F40002BEB4;
|
||||
packageReferences = (
|
||||
5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */,
|
||||
5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
|
||||
);
|
||||
productRefGroup = 5CA059CB279559F40002BEB4 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -861,6 +865,7 @@
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */,
|
||||
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
|
||||
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
|
||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
|
||||
@@ -1155,7 +1160,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1176,7 +1181,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.1;
|
||||
MARKETING_VERSION = 3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1197,7 +1202,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1218,7 +1223,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.1;
|
||||
MARKETING_VERSION = 3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1276,7 +1281,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1289,7 +1294,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.1;
|
||||
MARKETING_VERSION = 3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1306,7 +1311,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1319,7 +1324,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.1;
|
||||
MARKETING_VERSION = 3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1337,7 +1342,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1361,7 +1366,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 3.1;
|
||||
MARKETING_VERSION = 3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1383,7 +1388,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1407,7 +1412,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 3.1;
|
||||
MARKETING_VERSION = 3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1475,6 +1480,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.1.4;
|
||||
};
|
||||
};
|
||||
5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/twostraws/CodeScanner";
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-introspect",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/siteline/SwiftUI-Introspect",
|
||||
"state" : {
|
||||
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
|
||||
"version" : "0.1.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -24,7 +24,7 @@ public enum ChatCommand {
|
||||
case apiImportArchive(config: ArchiveConfig)
|
||||
case apiDeleteStorage
|
||||
case apiGetChats
|
||||
case apiGetChat(type: ChatType, id: Int64)
|
||||
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
|
||||
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent)
|
||||
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
|
||||
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
|
||||
@@ -45,6 +45,7 @@ public enum ChatCommand {
|
||||
case setUserSMPServers(smpServers: [String])
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
case apiGetNetworkConfig
|
||||
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
|
||||
case apiContactInfo(contactId: Int64)
|
||||
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
|
||||
case addContact
|
||||
@@ -85,7 +86,8 @@ public enum ChatCommand {
|
||||
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
|
||||
case .apiDeleteStorage: return "/_db delete"
|
||||
case .apiGetChats: return "/_get chats pcc=on"
|
||||
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
|
||||
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
|
||||
(search == "" ? "" : " search=\(search)")
|
||||
case let .apiSendMessage(type, id, file, quotedItemId, mc):
|
||||
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
|
||||
return "/_send \(ref(type, id)) json \(msg)"
|
||||
@@ -107,6 +109,7 @@ public enum ChatCommand {
|
||||
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
case .apiGetNetworkConfig: return "/network"
|
||||
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
|
||||
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
|
||||
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
|
||||
case .addContact: return "/connect"
|
||||
@@ -169,6 +172,7 @@ public enum ChatCommand {
|
||||
case .setUserSMPServers: return "setUserSMPServers"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
|
||||
case .apiSetChatSettings: return "apiSetChatSettings"
|
||||
case .apiContactInfo: return "apiContactInfo"
|
||||
case .apiGroupMemberInfo: return "apiGroupMemberInfo"
|
||||
case .addContact: return "addContact"
|
||||
@@ -255,7 +259,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case contactsList(contacts: [Contact])
|
||||
// group events
|
||||
case groupCreated(groupInfo: GroupInfo)
|
||||
case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact)
|
||||
case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact, member: GroupMember)
|
||||
case userAcceptedGroupSent(groupInfo: GroupInfo)
|
||||
case userDeletedMember(groupInfo: GroupInfo, member: GroupMember)
|
||||
case leftMemberUser(groupInfo: GroupInfo)
|
||||
@@ -268,11 +272,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case leftMember(groupInfo: GroupInfo, member: GroupMember)
|
||||
case groupDeleted(groupInfo: GroupInfo, member: GroupMember)
|
||||
case contactsMerged(intoContact: Contact, mergedContact: Contact)
|
||||
case groupInvitation(groupInfo: GroupInfo)
|
||||
case groupInvitation(groupInfo: GroupInfo) // unused
|
||||
case userJoinedGroup(groupInfo: GroupInfo)
|
||||
case joinedGroupMember(groupInfo: GroupInfo, member: GroupMember)
|
||||
case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember)
|
||||
case groupRemoved(groupInfo: GroupInfo)
|
||||
case groupRemoved(groupInfo: GroupInfo) // unused
|
||||
case groupUpdated(toGroup: GroupInfo)
|
||||
// receiving file events
|
||||
case rcvFileAccepted(chatItem: AChatItem)
|
||||
@@ -436,7 +440,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
|
||||
case let .contactsList(contacts): return String(describing: contacts)
|
||||
case let .groupCreated(groupInfo): return String(describing: groupInfo)
|
||||
case let .sentGroupInvitation(groupInfo, contact): return "groupInfo: \(groupInfo)\ncontact: \(contact)"
|
||||
case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)"
|
||||
case let .userAcceptedGroupSent(groupInfo): return String(describing: groupInfo)
|
||||
case let .userDeletedMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)"
|
||||
case let .leftMemberUser(groupInfo): return String(describing: groupInfo)
|
||||
@@ -484,6 +488,20 @@ public enum ChatResponse: Decodable, Error {
|
||||
private var noDetails: String { get { "\(responseType): no details" } }
|
||||
}
|
||||
|
||||
public enum ChatPagination {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
case before(chatItemId: Int64, count: Int)
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .last(count): return "count=\(count)"
|
||||
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
||||
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposedMessage: Encodable {
|
||||
var filePath: String?
|
||||
var quotedItemId: Int64?
|
||||
@@ -502,6 +520,8 @@ public struct ArchiveConfig: Encodable {
|
||||
|
||||
public struct NetCfg: Codable, Equatable {
|
||||
public var socksProxy: String? = nil
|
||||
public var hostMode: HostMode = .publicHost
|
||||
public var requiredHostMode = true
|
||||
public var tcpConnectTimeout: Int // microseconds
|
||||
public var tcpTimeout: Int // microseconds
|
||||
public var tcpKeepAlive: KeepAliveOpts?
|
||||
@@ -526,6 +546,46 @@ public struct NetCfg: Codable, Equatable {
|
||||
public var enableKeepAlive: Bool { tcpKeepAlive != nil }
|
||||
}
|
||||
|
||||
public enum HostMode: String, Codable {
|
||||
case onionViaSocks
|
||||
case onionHost = "onion"
|
||||
case publicHost = "public"
|
||||
}
|
||||
|
||||
public enum OnionHosts: String, Identifiable {
|
||||
case no
|
||||
case prefer
|
||||
case require
|
||||
|
||||
public var text: LocalizedStringKey {
|
||||
switch self {
|
||||
case .no: return "No"
|
||||
case .prefer: return "When available"
|
||||
case .require: return "Requred"
|
||||
}
|
||||
}
|
||||
|
||||
public var hostMode: (HostMode, Bool) {
|
||||
switch self {
|
||||
case .no: return (.publicHost, true)
|
||||
case .prefer: return (.onionHost, false)
|
||||
case .require: return (.onionHost, true)
|
||||
}
|
||||
}
|
||||
|
||||
public init(netCfg: NetCfg) {
|
||||
switch netCfg.hostMode {
|
||||
case .onionViaSocks: self = .no
|
||||
case .onionHost: self = netCfg.requiredHostMode ? .require : .prefer
|
||||
case .publicHost: self = .no
|
||||
}
|
||||
}
|
||||
|
||||
public var id: OnionHosts { self }
|
||||
|
||||
public static let values: [OnionHosts] = [.no, .prefer, .require]
|
||||
}
|
||||
|
||||
public struct KeepAliveOpts: Codable, Equatable {
|
||||
public var keepIdle: Int // seconds
|
||||
public var keepIntvl: Int // seconds
|
||||
@@ -534,6 +594,16 @@ public struct KeepAliveOpts: Codable, Equatable {
|
||||
public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4)
|
||||
}
|
||||
|
||||
public struct ChatSettings: Codable {
|
||||
public var enableNtfs: Bool
|
||||
|
||||
public init(enableNtfs: Bool) {
|
||||
self.enableNtfs = enableNtfs
|
||||
}
|
||||
|
||||
public static let defaults: ChatSettings = ChatSettings(enableNtfs: true)
|
||||
}
|
||||
|
||||
public struct ConnectionStats: Codable {
|
||||
public var rcvServers: [String]?
|
||||
public var sndServers: [String]?
|
||||
|
||||
@@ -15,6 +15,7 @@ public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart"
|
||||
let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode"
|
||||
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
|
||||
let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount"
|
||||
let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts"
|
||||
let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout"
|
||||
let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout"
|
||||
let GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL = "networkSMPPingInterval"
|
||||
@@ -29,6 +30,7 @@ public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)!
|
||||
|
||||
public func registerGroupDefaults() {
|
||||
groupDefaults.register(defaults: [
|
||||
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
|
||||
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout,
|
||||
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout,
|
||||
GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval,
|
||||
@@ -84,6 +86,12 @@ public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults
|
||||
|
||||
public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT)
|
||||
|
||||
public let networkUseOnionHostsGroupDefault = EnumDefault<OnionHosts>(
|
||||
defaults: groupDefaults,
|
||||
forKey: GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS,
|
||||
withDefault: .no
|
||||
)
|
||||
|
||||
public class DateDefault {
|
||||
var defaults: UserDefaults
|
||||
var key: String
|
||||
@@ -157,6 +165,8 @@ public class Default<T> {
|
||||
}
|
||||
|
||||
public func getNetCfg() -> NetCfg {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (hostMode, requiredHostMode) = onionHosts.hostMode
|
||||
let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT)
|
||||
let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT)
|
||||
let smpPingInterval = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL)
|
||||
@@ -171,6 +181,8 @@ public func getNetCfg() -> NetCfg {
|
||||
tcpKeepAlive = nil
|
||||
}
|
||||
return NetCfg(
|
||||
hostMode: hostMode,
|
||||
requiredHostMode: requiredHostMode,
|
||||
tcpConnectTimeout: tcpConnectTimeout,
|
||||
tcpTimeout: tcpTimeout,
|
||||
tcpKeepAlive: tcpKeepAlive,
|
||||
@@ -179,6 +191,7 @@ public func getNetCfg() -> NetCfg {
|
||||
}
|
||||
|
||||
public func setNetCfg(_ cfg: NetCfg) {
|
||||
networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg))
|
||||
groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT)
|
||||
groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT)
|
||||
groupDefaults.set(cfg.smpPingInterval, forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL)
|
||||
|
||||
@@ -189,6 +189,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
public var ntfsEnabled: Bool {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.chatSettings.enableNtfs
|
||||
case let .group(groupInfo): return groupInfo.chatSettings.enableNtfs
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var createdAt: Date {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.createdAt
|
||||
@@ -244,6 +252,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var profile: Profile
|
||||
public var activeConn: Connection
|
||||
public var viaGroup: Int64?
|
||||
public var chatSettings: ChatSettings
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
@@ -264,6 +273,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
localDisplayName: "alice",
|
||||
profile: Profile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
)
|
||||
@@ -433,6 +443,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
var localDisplayName: GroupName
|
||||
public var groupProfile: GroupProfile
|
||||
public var membership: GroupMember
|
||||
public var chatSettings: ChatSettings
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
@@ -461,6 +472,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
localDisplayName: "team",
|
||||
groupProfile: GroupProfile.sampleData,
|
||||
membership: GroupMember.sampleData,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
)
|
||||
@@ -710,7 +722,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
self.quotedItem = quotedItem
|
||||
self.file = file
|
||||
}
|
||||
|
||||
|
||||
public var chatDir: CIDirection
|
||||
public var meta: CIMeta
|
||||
public var content: CIContent
|
||||
@@ -718,9 +730,17 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
public var quotedItem: CIQuote?
|
||||
public var file: CIFile?
|
||||
|
||||
public var id: Int64 { get { meta.itemId } }
|
||||
public var viewTimestamp = Date.now
|
||||
|
||||
public var timestampText: Text { get { meta.timestampText } }
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatDir, meta, content, formattedText, quotedItem, file
|
||||
}
|
||||
|
||||
public var id: Int64 { meta.itemId }
|
||||
|
||||
public var viewId: String { "\(meta.itemId) \(viewTimestamp.timeIntervalSince1970)" }
|
||||
|
||||
public var timestampText: Text { meta.timestampText }
|
||||
|
||||
public var text: String {
|
||||
get {
|
||||
@@ -855,6 +875,7 @@ public struct CIMeta: Decodable {
|
||||
var itemText: String
|
||||
public var itemStatus: CIStatus
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
public var itemDeleted: Bool
|
||||
public var itemEdited: Bool
|
||||
public var editable: Bool
|
||||
@@ -868,6 +889,7 @@ public struct CIMeta: Decodable {
|
||||
itemText: text,
|
||||
itemStatus: status,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
itemDeleted: itemDeleted,
|
||||
itemEdited: itemEdited,
|
||||
editable: editable
|
||||
@@ -892,6 +914,17 @@ public enum CIStatus: Decodable {
|
||||
case sndError(agentError: AgentErrorType)
|
||||
case rcvNew
|
||||
case rcvRead
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .sndNew: return "sndNew"
|
||||
case .sndSent: return "sndSent"
|
||||
case .sndErrorAuth: return "sndErrorAuth"
|
||||
case .sndError: return "sndError"
|
||||
case .rcvNew: return "rcvNew"
|
||||
case .rcvRead: return "rcvRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum CIDeleteMode: String, Decodable {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
@@ -40,7 +39,7 @@ mySquaringBot _user cc = do
|
||||
race_ (forever $ void getLine) . forever $ do
|
||||
(_, resp) <- atomically . readTBQueue $ outputQ cc
|
||||
case resp of
|
||||
CRContactConnected contact -> do
|
||||
CRContactConnected contact _ -> do
|
||||
contactConnected contact
|
||||
void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square"
|
||||
CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# SimpleX Chat v3.1 is released
|
||||
|
||||
**Published:** Aug 8, 2022
|
||||
|
||||
## What's new
|
||||
|
||||
- [secret chat groups](#secret-chat-groups)!
|
||||
- [access to messaging servers via Tor](#access-to-messaging-servers-via-tor)
|
||||
- [advanced network settings](#advanced-network-settings)
|
||||
- [published chat protocol](#published-chat-protocol)
|
||||
- [new app icons](#new-app-icons)
|
||||
- [other changes since v3](#other-changes-since-v3)
|
||||
- optimized battery and traffic usage - up to 90x reduction!
|
||||
- two docker configurations for self-hosted SMP servers
|
||||
|
||||
### Secret chat groups
|
||||
|
||||
<img src="./images/20220808-group1.png" width="330"> <img src="./images/20220808-group2.png" width="330"> <img src="./images/20220808-group3.png" width="330">
|
||||
|
||||
It's been [nearly a year](./20210914-simplex-chat-v0.4-released.md) since the users of SimpleX Chat terminal app started experimenting with the groups, and now it is available to mobile app users as well. Many bugs were fixed, the stability was improved, but there are both the features we need to add and the bugs we need to fix to make groups more useful - we really look forward to your feedback. You can send any suggestions via the app by choosing `Chat with the developers` via app Settings (or using `/simplex` command in the terminal app) – this would connect you to SimpleX team via its [fixed chat address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D).
|
||||
|
||||
SimpleX network is decentralized, so how do groups work? Unlike Matrix or Signal that host the group profile and the list of group members on their servers, SimpleX servers have no information about the group's existence - only its members do. SimpleX network does not assign any globally unique identifiers to the group, there is only a local database identifier and the list of members stored on members' devices. A user has an independent connection to each member in a group. When a user sends a message to the group, the app sends this message independently to each member. You can read more about how groups work in [SimpleX Chat Protocol](../docs/protocol/simplex-chat.md#sub-protocol-for-chat-groups).
|
||||
|
||||
But how can it scale, you might ask? It simply won't, and the current design for the groups is only suitable for relatively small groups of people who know each other well, definitely not larger than few hundred members – this design prioritized privacy and security of the group over its size or performance. For example, to send a message to the group of 100 members a user would need to send a total of ~1.6mb of data (as each message uses a fixed size block of 16kb). And if you were to send a 1mb file then it would also require sending it 100 times (provided each member accepts it).
|
||||
|
||||
What if you need to send many large files to group members? We will be developing a file hosting server where the users will be able to upload the file (or image) once, and only send the file link and credentials to all group members, without the need to send the actual file. A small hosting quota will be available to all users for free, paid for by donations, and for larger files or to increase the total quota the users would either have to pay a small hosting cost or to self-host this server – it will be available as an open-source code.
|
||||
|
||||
What if you need to have a large group - e.g. 100,000 members or more? We will be introducing SimpleX channels later this year, that can be both public and private. These channels would require a server to host them, either provided by SimpleX Chat or self-hosted - same as for a website. If this is a public channel it would be optionally accessible via the web browser as well, and it will be possible to embed it into any webpage.
|
||||
|
||||
Other group improvements we will add soon:
|
||||
|
||||
- manage notifications in each group independently.
|
||||
- search for messages - it is useful for direct conversations too, but more important for groups.
|
||||
|
||||
### Access messaging servers via Tor
|
||||
|
||||
<img src="./images/20220808-tor1.png" width="330"> <img src="./images/20220808-tor2.png" width="330">
|
||||
|
||||
SimpleX protocols are focussed on protecting application-level meta-data – they don't have any user identifiers used by all other messaging platforms, instead relying on pairwise connection identifiers.
|
||||
|
||||
But there are scenarios when users need to protect their IP addresses from the servers and any network observers – this is best done by using Tor to access any network services.
|
||||
|
||||
This release allows to access SimpleX messaging servers via Tor on all platforms:
|
||||
|
||||
- terminal app beta supported it for a couple weeks now: to access SimpleX servers via Tor you need to install Tor proxy and run simplex-chat with `-x` option. See [terminal app docs](../docs/CLI.md#access-messaging-servers-via-tor-beta) for more information.
|
||||
- Android app supports access via Tor using Orbot SOCKS proxy. Once you install and start Orbot, you need to enable `Network & Servers / Use SOCKS proxy` setting in the app to access SimpleX servers via Tor.
|
||||
- iOS app can also be used with Orbot iOS app (that is installed as a system-wide VPN provider). The only setting you might need to change is to increase network timeouts in the app - to do that you have to enable `Developer tools`, and then chose `Network & Servers / Advanced network settings / Set timeouts for proxy`.
|
||||
|
||||
Currently all servers are accessed via their public Internet addresses, and while users can self-host messaging servers on .onion addresses (as v3 hidden services), it would require both connected contacts to use Tor. We are planning to add support for dual server addresses very soon, to allow the same server to be accessed both via its public Internet address and via .onion address, so that users can access servers without exiting Tor (via .onion address), but their contacts can access the same servers without using Tor.
|
||||
|
||||
### Advanced network settings
|
||||
|
||||
To reduce traffic on slow networks we added network access settings. To use these settings, you need to first enable `Developer tools` and then choose `Network & Servers / Advanced network settings`:
|
||||
|
||||
- if your connections to the servers are unstable, and you frequently see the spinners in the list of chats, please increase the connection and protocol timeouts - it should reduce the traffic, but it may also make the app a bit slower when your Internet connection is slow.
|
||||
- if your connection to the servers appears stable, but the traffic usage is high, please try disabling TCP keep-alive setting or increasing keep-alive idle period (`TCP_KEEP_IDLE`) and interval (`TCP_KEEP_INTVL`).
|
||||
|
||||
Once we investigated how these settings affect traffic and user experience we will simplify them - huge thanks to everyone testing them and reporting any traffic issues.
|
||||
|
||||
### New app icons
|
||||
|
||||
<img src="./images/20220808-icons.png" width="330">
|
||||
|
||||
Many users asked to allow customizing the app, this is just a start - you can now choose either light or dark icon option via `Appearance` settings.
|
||||
|
||||
More options to customize the app are coming - please let us know what are the most important.
|
||||
|
||||
### Published chat protocol
|
||||
|
||||
The [low level SimpleX protocols](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/) were published long time ago, and updated to reflect the evolution of the protocols, the high level chat protocol was not published before. The reason for that was to allow us to iterate it quickly, without committing to any of the decisions.
|
||||
|
||||
This is the [first draft of SimpleX Chat Protocol](../docs/protocol/simplex-chat.md) - let us know any questions or suggestions.
|
||||
|
||||
### Other changes since v3
|
||||
|
||||
Since v3 release we also optimized battery and traffic usage - with up to 90x traffic reduction in some cases – and published two docker configurations for self-hosted SMP servers. Read more about it in the previous [beta version announcement](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md).
|
||||
|
||||
## SimpleX platform
|
||||
|
||||
Some links to answer the most common questions:
|
||||
|
||||
[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers).
|
||||
|
||||
[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users).
|
||||
|
||||
[Technical details and limitations](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md#privacy-technical-details-and-limitations).
|
||||
|
||||
[How SimpleX is different from Session, Matrix, Signal, etc.](../README.md#frequently-asked-questions).
|
||||
|
||||
## We ask you to help us pay for 3rd party security audit
|
||||
|
||||
I will get straight to the point: I ask you to support SimpleX Chat with donations.
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
|
||||
|
||||
We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20000+ expense could be covered with donations.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations for the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
|
||||
|
||||
It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat): it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat): it also accepts donations in crypto-currencies, but charges a commission.
|
||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
@@ -1,5 +1,13 @@
|
||||
# Blog
|
||||
|
||||
Aug 8, 2022 [SimpleX Chat v3.1 released](./20220808-simplex-chat-v3.1-chat-groups.md)
|
||||
|
||||
- finally, secret chat groups!
|
||||
- access to messaging servers via Tor on all platforms
|
||||
- advanced network settings to optimize traffic usage
|
||||
- published chat protocol
|
||||
- new app icons
|
||||
|
||||
Jul 23, 2022 [SimpleX Chat v3.1-beta released](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md)
|
||||
|
||||
- terminal app: access to messaging servers via SOCKS5 proxy (e.g., Tor).
|
||||
|
||||
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 143 KiB |
@@ -5,7 +5,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 7d99c4b35cf2dc531219bc83146b714c9bae429c
|
||||
tag: a7b39b710c3aab9b2a38bd6841e52e0342b3a7ef
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
sequenceDiagram
|
||||
participant M as N existing<br>members
|
||||
participant A as Alice
|
||||
participant B as Bob
|
||||
participant C as Existing<br>contact
|
||||
|
||||
note over A, B: 1. send and accept group invitation
|
||||
A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection)
|
||||
B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)
|
||||
B ->> A: establish group member connection
|
||||
|
||||
note over M, B: 2. introduce new member Bob to all existing members
|
||||
A ->> M: x.grp.mem.new<br>"announce" Bob<br>to existing members<br>(via member connections)
|
||||
A ->> B: x.grp.mem.intro * N<br>"introduce" members<br>(via member connection)
|
||||
B ->> A: x.grp.mem.inv * N<br>"invitations" to connect<br>for all members<br>(via member connection)
|
||||
A ->> M: x.grp.mem.fwd<br>forward "invitations"<br>to all members<br>(via member connections)
|
||||
|
||||
note over M, B: 3. establish direct and group member connections
|
||||
M ->> B: establish group member connection
|
||||
M ->> B: establish direct connection
|
||||
|
||||
note over M, C: 4. deduplicate new contact
|
||||
B ->> M: x.info.probe<br>"probe" is sent to all new members
|
||||
B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match
|
||||
C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user
|
||||
note over B: merge existing and new contacts if received and sent probe hashes match
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,236 @@
|
||||
DRAFT Revision 0.1, 2022-08-08
|
||||
|
||||
Evgeny Poberezkin
|
||||
|
||||
# SimpleX Chat Protocol
|
||||
|
||||
## Abstract
|
||||
|
||||
SimpleX Chat Protocol is a protocol used to by SimpleX Chat clients to exchange messages. This protocol relies on lower level SimpleX protocols - SimpleX Messaging Protocol (SMP) and SimpleX Messaging Agent protocol. SimpleX Chat Protocol describes the format of messages and the client operations that should be performed when receiving such messages.
|
||||
|
||||
## Scope
|
||||
|
||||
The scope of SimpleX Chat Protocol is application level messages, both for chat functionality, related to the conversations between the clients, and extensible for any other application functions. Currently supported chat functions:
|
||||
|
||||
- direct and group messages,
|
||||
- message replies (quoting), forwarded messages and message deletions,
|
||||
- message attachments: images and files,
|
||||
- creating and managing chat groups,
|
||||
- invitation and signalling for audio/video WebRTC calls.
|
||||
|
||||
## General message format
|
||||
|
||||
SimpleX Chat protocol supports two message formats:
|
||||
|
||||
- JSON-based format for chat and application messages.
|
||||
- binary format for sending files or any other binary data.
|
||||
|
||||
### JSON format for chat and application messages
|
||||
|
||||
This document uses JTD schemas [RFC 8927](https://www.rfc-editor.org/rfc/rfc8927.html) to define the properties of chat messages, with some additional restrictions on message properties included in metadata member of JTD schemas. In case of any contradiction between JSON examples and JTD schema the latter MUST be considered correct.
|
||||
|
||||
Whitespace is used in JSON examples for readability, SimpleX Chat Protocol clients MUST avoid using whitespace when encoding JSON messages.
|
||||
|
||||
General message format is defined by this JTD schema:
|
||||
|
||||
```JSON
|
||||
{
|
||||
"properties": {
|
||||
"event": {
|
||||
"type": "string"
|
||||
},
|
||||
"msgId": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "base64url-encoded 12 bytes random message ID"
|
||||
}
|
||||
},
|
||||
"params": {
|
||||
"optionalProperties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For example, this message defines a simple text message `"hello!"`:
|
||||
|
||||
```JSON
|
||||
{
|
||||
"event": "x.msg.new",
|
||||
"msgId": "abcd",
|
||||
"params": {
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "hello!"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`msgId` property is a 12 bytes base64url-encoded random message ID that the clients can use to reference the message in the future, e.g. when editing, quoting or deleting it.
|
||||
|
||||
`event` property is the type of the message that defines the semantics of the message and the allowed format of `params` property.
|
||||
|
||||
`params` property includes message data, depending on `event`, as defined below and in [JTD schema](./simplex-chat.schema.json).
|
||||
|
||||
### Binary format for sending files
|
||||
|
||||
SimpleX Chat clients use separate connections to send files using a binary format. File chunk size send in each message MUST NOT be bigger than 15,780 bytes to fit into 16kb (16384 bytes) transport block.
|
||||
|
||||
The syntax of each message used to send files is defined by the following ABNF notation:
|
||||
|
||||
```abnf
|
||||
fileMessage = fileChunk / cancelFile
|
||||
fileChunk = %s"F" chunkNo chunk
|
||||
cancelFile = %s"C"
|
||||
chunkNo = 4*4 OCTET ; 32bit word sequential chunk number, starting from 1, in network byte order
|
||||
chunk = 1*15780 OCTET ; file data, up to 15,780 bytes
|
||||
```
|
||||
|
||||
The first chunk number MUST be 1.
|
||||
|
||||
## Messages and chat items
|
||||
|
||||
While users usually use the term "message" to refer to the objects presented in the conversation, the expected functionality of these objects makes it a wrong term. "Messages" are supposed to be immutable; they cannot be modified or deleted once sent. The objects in the conversation are expected to be mutable. This document and implementation use the term "chat item" to refer to these objects to differentiate them from the messages sent between the clients.
|
||||
|
||||
## Supported JSON message types and SimpleX Chat sub-protocols
|
||||
|
||||
Message types are sent as a string in `event` property of JSON messages. General syntax of event string is defined by this ABNF:
|
||||
|
||||
```abnf
|
||||
event = namespace "." subprotocol *("." eventWord)
|
||||
namespace = eventWord ; 1-letter recommended
|
||||
subprotocol = eventWord
|
||||
eventWord = 1* ALPHA
|
||||
```
|
||||
|
||||
All SimpleX Chat Protocol messages related to chat functions are defined in `x` namespace.
|
||||
|
||||
Sub-protocol is a group of messages for related message functions - e.g. sending files, managing groups or negotiating WebRTC calls.
|
||||
|
||||
SimpleX Chat Protocol supports the following message types passed in `event` property:
|
||||
|
||||
- `x.contact` - contact profile and additional data sent as part of contact request to a long-term contact address.
|
||||
- `x.info*` - messages to send, update and de-duplicate contact profiles.
|
||||
- `x.msg.*` - messages to create, update and delete content chat items.
|
||||
- `x.file.*` - messages to accept and cancel sending files (see files sub-protocol).
|
||||
- `x.grp.*` - messages used to manage groups and group members (see group sub-protocol).
|
||||
- `x.call.*` - messages to invite to WebRTC calls and send signalling messages.
|
||||
- `x.ok` - message sent during connection handshake.
|
||||
|
||||
JTD schema defining messages for all chat functions is available in [this file](./simplex-chat.schema.json) – please refer to this document for all properties of the message `params`.
|
||||
|
||||
## x.contact - sending connection request
|
||||
|
||||
The message is sent as part of the connection request to the long-term user address. `contactReqId` property is used to identify a duplicate contact request - the receiving client MAY put repeated request on top of the list in the UI.
|
||||
|
||||
## Sub-protocol for contact profile
|
||||
|
||||
### x.info - contact profile
|
||||
|
||||
This message is sent by both sides of the connection during the connection handshake, and can be sent later as well when contact profile is updated.
|
||||
|
||||
### Probing for duplicate contacts
|
||||
|
||||
As there are no globally unique user identitifiers, when the contact a user is already connected to is added to the group by some other group member, this contact will be added to user's list of contacts as a new contact. To allow merging such contacts, "a probe" (random base64url-encoded 32 bytes) SHOULD be sent to all new members as part of `x.info.probe` message and, in case there is a contact with the same profile, the hash of the probe MAY be sent to it as part of `x.info.probe.check` message. In case both the new member and the existing contact are the same user (they would receive both the probe and its hash), the contact would send back the original probe as part of `x.info.probe.ok` message via the previously existing contact connection – proving to the sender that this new member and the existing contact are the same user, in which case the sender SHOULD merge these two contacts.
|
||||
|
||||
Sending clients MAY disable this functionality, and receiving clients MAY ignore probe messages.
|
||||
|
||||
If the sending client uses `x.info.probe` messages, it MUST send them to all new members, rather than only when there is a matching contact profile. This is to avoid leaking information that the matching contact profile exists.
|
||||
|
||||
## Sub-protocol for content messages
|
||||
|
||||
### x.msg.new - a new content message
|
||||
|
||||
When chat clients receive or send this message, they MUST create a new chat item in the conversation. Top level `msgId` property is defined to allow referencing this chat item or message in the future, e.g. to delete, update or quote chat item, or to accept file.
|
||||
|
||||
This message uses `params` property of the message as content message container, without any top level properties for the container. Message container (`params`) includes message `content` property, an optional "invitation" to receive file or image attachment in `file` property (that is interpreted depending on message content type) and optional indication whether this message is forwarded (`"forward": true` property of container) or sent in reply to other message (`"quote": {<quoted message>}`). See `/definition/msgContainer` in [JTD schema](./simplex-chat.schema.json) for message container format.
|
||||
|
||||
Message content can be one of four types:
|
||||
|
||||
- `text` - no file attachment is expected for this format, `text` property MUST be non-empty.
|
||||
- `file` - attached file is required, `text` property MAY be empty.
|
||||
- `image` - attached file is required, `text` property MAY be empty.
|
||||
- `link` - no file attachment is expected, `text` property MUST be non-empty. `preview` property contains information about link preview.
|
||||
|
||||
See `/definition/msgContent` in [JTD schema](./simplex-chat.schema.json) for message container format.
|
||||
|
||||
### x.msg.update - update of the previously sent message
|
||||
|
||||
This message is used to update previously created chat item. Its `params` property contains `msgId` of the previously sent message that this one is updating and `content` with the message content that the clients must use to replace the content of the original chat item.
|
||||
|
||||
If the referenced message does not exist, the clients MUST create a new chat item with the ID of the referenced message. If the referenced message is not a content message, the clients MUST ignore this message.
|
||||
|
||||
### x.msg.del - request to delete previously sent message
|
||||
|
||||
This message is used to delete previously sent chat items. Receiving clients MUST implement it as soft-delete, replacing the original chat item with a special chat item indicating that "message is deleted" that can be fully deleted by the user. If the referenced message does not exist or was sent by the different user than the one sending `x.msg.del`, the receiving clients MUST ignore this message. Clients are also RECOMMENDED to limit the time during which message deletion is allowed, both for senders and for the recipients.
|
||||
|
||||
## Sub-protocol for sending and receiving files
|
||||
|
||||
When content message `x.msg.new` contains file attachment (the invitation to receive the file), this sub-protocol is used to accept this file or to notify the recipient that sending the file was cancelled.
|
||||
|
||||
File attachement can optionally include connection address to receive the file - clients MUST include it when sending files to direct connections, and MUST NOT include it when sending file attachment to the group (as different members would need different connections to receive the file).
|
||||
|
||||
`x.file.acpt` message is used to accept the file in case when file connection address was included in the message (that is the case when the file invitation was sent in direct message). It is sent as part of file connection handshake via file connection, that is why this message contains no reference to the file - the used connection provides sufficient context for the sender.
|
||||
|
||||
`x.file.acpt.inv` message is used to accept the file in group conversations, it includes the connection address. It is sent in the same connection where the file was offered and must reference the original message.
|
||||
|
||||
`x.file.cancel` message is sent to notify the recipient that sending of the file was cancelled. It is sent in response to accepting the file with `x.file.acpt.inv` message. It is sent in the same connection where the file was offered.
|
||||
|
||||
## Sub-protocol for chat groups
|
||||
|
||||
### Decentralized design for chat groups
|
||||
|
||||
SimpleX Chat groups are fully decentralized and do not have any globally unique group identifiers - they are only defined on client devices as a group profile and a set of bi-directional SimpleX connections with other group members. When a new member accepts group invitation, the inviting member introduces a new member to all existing members and forwards the connection addresses so that they can establish direct and group member connections.
|
||||
|
||||
There is a possibility of the attack here: as the introducing member forwards the addresses, they can substitute them with other addresses, performing MITM attack on the communication between existing and introduced members - this is similar to the communication operator being able to perform MITM on any connection between the users. To mitigate this attack this group sub-protocol will be extended to allow validating security of the connection by sending connection verification out-of-band.
|
||||
|
||||
Clients are RECOMMENDED to indicate in the UI whether the connection to a group member or contact was made directly or via annother user.
|
||||
|
||||
Each member in the group is identified by a group-wide unique identifier used by all members in the group. This is to allow referencing members in the messages and to allow group message integrity validation.
|
||||
|
||||
The diagram below shows the sequence of messages sent between the users' clients to add the new member to the group.
|
||||
|
||||

|
||||
|
||||
### Member roles
|
||||
|
||||
Currently members can have one of three roles - `owner`, `admin` and `member`. The user that created the group is self-assigned owner role, the new members are assigned role by the member who adds them - only `owner` and `admin` members can add new members; only `owner` members can add members with `owner` role.
|
||||
|
||||
### Messages to manage groups and add members
|
||||
|
||||
`x.grp.inv` message is sent to invite contact to the group via contact's direct connection and includes group member connection address. This message MUST only be sent by members with `admin` or `owner` role.
|
||||
|
||||
`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user.
|
||||
|
||||
`x.grp.mem.new` message is sent by the inviting user to all connected members (and scheduled as pending to all announced but not yet connected members) to announce a new member to the existing members. This message MUST only be sent by members with `admin` or `owner` role. Receiving clients MUST ignore this message if it is received from member with `member` role.
|
||||
|
||||
`x.grp.mem.intro` messages are sent by the inviting user to the invited member, via their group member connection, one message for each existing member. When this message is sent by any other member than the one who invited the recipient it MUST be ignored.
|
||||
|
||||
`x.grp.mem.inv` messages are sent by the invited user to the inviting user, one message for each existing member previously introduced with `x.grp.mem.intro` message. When this message is sent by any other member than the one who was invited by the recipient it MUST be ignored.
|
||||
|
||||
`x.grp.mem.fwd` message is used by the inviting user to forward the invitations received from invited member in `x.grp.mem.inv` messages to all other members. This message can only be sent by the member who previously announced the new member, otherwise the recipients MUST ignore it.
|
||||
|
||||
`x.grp.mem.info` this message is sent as part of member connection handshake - it includes group member profile.
|
||||
|
||||
`x.grp.mem.del` message is sent to delete a member - it is sent to all members by the member who deletes the member referenced in this message. This message MUST only be sent by members with `admin` or `owner` role. Receiving clients MUST ignore this message if it is received from member with `member` role.
|
||||
|
||||
`x.grp.leave` message is sent to all members by the member leaving the group. If the only group `owner` leaves the group, it will not be possible to delete it with `x.grp.del` message - but all members can still leave the group with `x.grp.leave` message and then delete a local copy of the group.
|
||||
|
||||
`x.grp.del` message is sent to all members by the member who deletes the group. Clients who received this message SHOULD keep a local copy of the deleted group, until it is deleted by the user. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role.
|
||||
|
||||
`x.grp.info` message is sent to all members by the member who updated group profile. Only group owners can update group profiles. Clients MAY implement some conflict resolution strategy - it is currently not implemented by SimpleX Chat client. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role.
|
||||
|
||||
## Sub-protocol for WebRTC audio/video calls
|
||||
|
||||
This sub-protocol is used to send call invitations and to negotiate end-to-end encryption keys and pass WebRTC signalling information.
|
||||
|
||||
These message are used for WebRTC calls:
|
||||
|
||||
1. `x.call.inv`: the client initiating the call sends `x.call.inv` message in direct connection to invite another client to the call. At this point WebRTC session is not initialized yet, this message only contains call type and DH key for key agreement.
|
||||
|
||||
2. `x.call.offer`: to accept the call, the receiving client sends `x.call.offer` message. This message contains WebRTC offer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message.
|
||||
|
||||
3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message.
|
||||
|
||||
4. `x.call.end` message is sent to notify the other party that the call is terminated.
|
||||
@@ -0,0 +1,488 @@
|
||||
{
|
||||
"metadata": {
|
||||
"description": "JTD schema for SimpleX Chat Protocol messages for chat functions"
|
||||
},
|
||||
"definitions": {
|
||||
"profile": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "non-empty string without spaces, the first character must not be # or @"
|
||||
}
|
||||
},
|
||||
"fullName": {"type": "string"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "data URI format for base64 encoded image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"msgContainer": {
|
||||
"properties": {
|
||||
"content": {"ref": "msgContent"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"file": {"ref": "fileInvitation"},
|
||||
"quote": {
|
||||
"properties": {
|
||||
"msgRef": {"ref": "msgRef"},
|
||||
"content": {"ref": "msgContent"}
|
||||
}
|
||||
},
|
||||
"forward": {"type": "boolean"}
|
||||
},
|
||||
"metadata": {
|
||||
"comment": "optional properties `quote` and `forward` are mutually exclusive"
|
||||
}
|
||||
},
|
||||
"msgContent": {
|
||||
"discriminator": "type",
|
||||
"mapping": {
|
||||
"text": {
|
||||
"properties": {
|
||||
"text": {"type": "string", "metadata": {"comment": "non-empty"}}
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"properties": {
|
||||
"text": {"type": "string", "metadata": {"comment": "non-empty"}},
|
||||
"preview": {"ref": "linkPreview"}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
|
||||
"image": {"ref": "base64url"}
|
||||
},
|
||||
"file": {
|
||||
"text": {"type": "string", "metadata": {"comment": "can be empty"}}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"comment": "it is RECOMMENDED that the clients support other values in `type` properties showing them as text messages in case `text` property is present"
|
||||
}
|
||||
},
|
||||
"msgRef": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"sentAt": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "ISO8601 UTC time of the message"
|
||||
}
|
||||
},
|
||||
"sent": {"type": "boolean"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"metadata": {
|
||||
"comment": "memberId must be present in all group message references, both for sent and received"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileInvitation": {
|
||||
"properties": {
|
||||
"fileName": {"type": "string"},
|
||||
"fileSize": {"type": "uint32"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"fileConnReq": {"ref": "connReqUri"}
|
||||
}
|
||||
},
|
||||
"linkPreview": {
|
||||
"properties": {
|
||||
"uri": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"image": {"ref": "base64url"}
|
||||
}
|
||||
},
|
||||
"groupInvitation": {
|
||||
"properties": {
|
||||
"fromMember": {"ref": "memberIdRole"},
|
||||
"invitedMember": {"ref": "memberIdRole"},
|
||||
"connRequest": {"ref": "connReqUri"},
|
||||
"groupProfile": {"ref": "profile"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"fromMemberProfile": {"ref": "profile"},
|
||||
"metadata": {
|
||||
"comment": "fromMemberProfile is user's custom profile to be used in the group - invitee should use this profile for the host's group member"
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberIdRole": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"memberRole": {"ref": "groupMemberRole"}
|
||||
}
|
||||
},
|
||||
"memberInfo": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"memberRole": {"ref": "groupMemberRole"},
|
||||
"profile": {"ref": "profile"}
|
||||
}
|
||||
},
|
||||
"introInvitation": {
|
||||
"properties": {
|
||||
"groupConnReq": {"ref": "connReqUri"},
|
||||
"directConnReq": {"ref": "connReqUri"}
|
||||
}
|
||||
},
|
||||
"callInvitation": {
|
||||
"properties": {
|
||||
"callType": {"ref": "callType"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"callDhPubKey": {"ref": "base64url"}
|
||||
}
|
||||
},
|
||||
"callOffer": {
|
||||
"properties": {
|
||||
"callType": {"ref": "callType"},
|
||||
"rtcSession": {"ref": "webRTCSession"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"callDhPubKey": {"ref": "base64url"}
|
||||
}
|
||||
},
|
||||
"callAnswer": {
|
||||
"properties": {
|
||||
"rtcSession": {"ref": "webRTCSession"}
|
||||
}
|
||||
},
|
||||
"callExtraInfo": {
|
||||
"properties": {
|
||||
"rtcExtraInfo": {
|
||||
"properties": {
|
||||
"rtcIceCandidates": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"callType": {
|
||||
"properties": {
|
||||
"media": {"enum": ["audio", "video"]},
|
||||
"capabilities": {
|
||||
"properties": {
|
||||
"encryption": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webRTCSession": {
|
||||
"properties": {
|
||||
"rtcSession": {"type": "string"},
|
||||
"rtcIceCandidates": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"base64url": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "base64url encoded string"
|
||||
}
|
||||
},
|
||||
"connReqUri": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"format": "URI for connection request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discriminator": "event",
|
||||
"mapping": {
|
||||
"x.contact": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"profile": {"ref": "profile"},
|
||||
"contactReqId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.info": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"profile": {"ref": "profile"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.info.probe": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"probe": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.info.probe.check": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"probeHash": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.info.probe.ok": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"probe": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.msg.new": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {"ref": "msgContainer"}
|
||||
}
|
||||
},
|
||||
"x.msg.update": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"content": {"ref": "msgContent"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.msg.del": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.file.acpt": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"fileName": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.file.acpt.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"fileConnReq": {"ref": "connReqUri"},
|
||||
"fileName": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.file.cancel": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"groupInvitation": {"ref": "groupInvitation"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.acpt": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"memberProfile": {"ref": "profile"},
|
||||
"metadata": {
|
||||
"comment": "memberProfile is user's custom profile to be used in the group - host should use this profile for the invitee's group member"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.new": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberInfo": {"ref": "memberInfo"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.intro": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberInfo": {"ref": "memberInfo"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"memberIntro": {"ref": "introInvitation"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.fwd": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberInfo": {"ref": "memberInfo"},
|
||||
"memberIntro": {"ref": "introInvitation"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.info": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"},
|
||||
"profile": {"ref": "profile"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.mem.del": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"memberId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.leave": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.del": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.grp.info": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"groupProfile": {"ref": "profile"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.call.inv": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"callId": {"ref": "base64url"},
|
||||
"invitation": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.call.offer": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"callId": {"ref": "base64url"},
|
||||
"offer": {"ref": "callOffer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.call.answer": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"callId": {"ref": "base64url"},
|
||||
"answer": {"ref": "callAnswer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.call.extra": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"callId": {"ref": "base64url"},
|
||||
"extra": {"ref": "callExtraInfo"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.call.end": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {
|
||||
"callId": {"ref": "base64url"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x.ok": {
|
||||
"properties": {
|
||||
"msgId": {"ref": "base64url"},
|
||||
"params": {
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// x. namespace is for chat messages transmitted inside SMP agent MSG
|
||||
type MemberMessageType =
|
||||
| "x.grp.info" // group profile information or update
|
||||
| "x.grp.off" // disable group
|
||||
| "x.grp.del" // group deleted
|
||||
| "x.grp.mem.new" // new group member
|
||||
| "x.grp.mem.acl" // group member permissions (ACL)
|
||||
| "x.grp.mem.leave" // group member left
|
||||
| "x.grp.mem.off" // suspend group member
|
||||
| "x.grp.mem.on" // enable group member
|
||||
| "x.grp.mem.del" // group member removed
|
||||
|
||||
type ProfileMessageType =
|
||||
| "x.info" // profile information or update
|
||||
| "x.info.grp" // information about group in profile
|
||||
| "x.info.con" // information about contact in profile
|
||||
|
||||
type NotificationMessageType = "x.msg.read"
|
||||
|
||||
type OpenConnMessageType =
|
||||
| "x.open.grp" // open invitation to the group
|
||||
| "x.open.con" // open invitation to the contact
|
||||
|
||||
type ContentMessageType =
|
||||
| "x.msg.new" // new message
|
||||
| "x.msg.append" // additional part of the message
|
||||
| "x.msg.del" // delete message
|
||||
| "x.msg.update" // update message
|
||||
| "x.msg.fwd" // forward message
|
||||
| "x.msg.reply" // reply to message
|
||||
|
||||
// TODO namespace for chat messages transmitted as other agent messages
|
||||
|
||||
type DirectMessageType =
|
||||
| ProfileMessageType
|
||||
| NotificationMessageType
|
||||
| OpenConnMessageType
|
||||
| ContentMessageType
|
||||
|
||||
type GroupMessageType = MemberMessageType | DirectMessageType
|
||||
|
||||
type ContentType =
|
||||
| "c.text"
|
||||
| "c.html"
|
||||
| "c.image"
|
||||
| "c.audio"
|
||||
| "c.video"
|
||||
| "c.doc"
|
||||
| "c.sticker"
|
||||
| "c.file"
|
||||
| "c.link"
|
||||
| "c.form"
|
||||
| "c.poll"
|
||||
| "c.applet"
|
||||
|
||||
// the type of message data transmitted inside SMP agent MSG
|
||||
interface MessageData<T extends GroupMessageType> {
|
||||
type: T
|
||||
sent: Date
|
||||
data: unknown
|
||||
}
|
||||
|
||||
interface DirectMessageData<T extends DirectMessageType> extends MessageData<T> {}
|
||||
|
||||
interface GroupMessageData<T extends GroupMessageType> extends MessageData<T> {
|
||||
msgId: number
|
||||
parents: ParentMessage[]
|
||||
}
|
||||
|
||||
interface ParentMessage {
|
||||
memberId: Uint8Array
|
||||
msgId: number
|
||||
msgHash: Uint8Array
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Incognito connections
|
||||
|
||||
## Problems
|
||||
|
||||
Allow users to connect with incognito profile using the same user account without exposing main profile - either with randomly generated per connection profile / no profile, or custom profile created by user per connection. The latter option involves designing complex UI for creating profile on connection and seems to detract from UX, so we consider first two options.
|
||||
|
||||
## Proposal
|
||||
|
||||
Add incognito mode determining whether newly created connections are incognito, and a switch on connection pages affecting current connection.
|
||||
|
||||
Add API to turn incognito mode on/off - it is saved as part of ChatController state to allow terminal users to set it. We can save preference on mobile and set it on chat start. We can also persist it to database to carry across terminal sessions, but it seems unnecessary.
|
||||
|
||||
Parameterize `AddContact`, `Connect` and `ConnectSimplex` API - create connection as incognito based on incognito mode and parameter, parameter is given preference.
|
||||
|
||||
### Option 1 - random profile
|
||||
|
||||
Add nullable field `custom_user_profile_id` to `connections` table - `incognitoProfile: Maybe Profile` in `Connection` type; when connection is created as incognito on API call, random profile is created and saved to `profiles` table.
|
||||
|
||||
Incognito profile is created only with a display name, it can be:
|
||||
|
||||
- Some prefix followed by sequence of random character/digits
|
||||
- Passphrase-like (2-4 random words)
|
||||
- A name from a dictionary(ies)?
|
||||
- One of above chosen randomly.
|
||||
|
||||
We could generate other parts of profile (picture?) but it's not necessary for MVP.
|
||||
|
||||
When user initiates connection as incognito, incognito profile is sent as part of XInfo upon receiving CONF from joining user.
|
||||
|
||||
### Option 2 - no profile
|
||||
|
||||
Add `incognito` flag to `connections` table - `incognito: Bool` in `Connection` type.
|
||||
|
||||
Instead of XInfo both in `Connect` API and on receiving CONF when initiating, send a message that doesn't contain profile, e.g. XOk.
|
||||
|
||||
When saving connection profile (`saveConnInfo`) or processing contact request on receiving XOk / other message, contact generates a random profile for the user to distinguish from other connections. He is also able to mark this connection as incognito.
|
||||
|
||||
### Considerations
|
||||
|
||||
- Don't broadcast user profile updates to contacts with whom the user has established incognito connections.
|
||||
- Add indication on chat info page that connection was established as incognito, show profile name so the user knows how the contact sees him.
|
||||
- While profile names generated in option 1 may be distinguishable as incognito depending on generator, technically the fact that connection was established as incognito is not explicitly leaked, which is clear with option 2.
|
||||
- We could offer same random profile generator on creating profiles, which would blend users with such profile as permanent and users who chose to connect with incognito profile to an observer (i.e. the fact that user chooses to be incognito for this specific connection is no longer leaked, just that he chose to be incognito generally).
|
||||
- There's a use case for custom incognito profile created by user for a given connection in case user wants to hide the fact of incognito connection (leaked by distinguishable pattern in profile name or lack of profile), but it may better be solved by multi-profile.
|
||||
- Send incognito profile when accepting contact requests in incognito mode? Parameterize API and give option in dialog?
|
||||
|
||||
### Groups
|
||||
|
||||
- If host used user's incognito connection when inviting, save same field marking group as incognito in `groups` table?
|
||||
- Use incognito profile in XGrpMemInfo
|
||||
- Allow host to create group in incognito profile - all connections with members are created as incognito?
|
||||
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 3.1.0
|
||||
version: 3.2.0
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
@@ -31,6 +31,7 @@ dependencies:
|
||||
- network >= 3.1.2.7 && < 3.2
|
||||
- optparse-applicative >= 0.15 && < 0.17
|
||||
- process == 1.6.*
|
||||
- random >= 1.1 && < 1.3
|
||||
- simple-logger == 0.1.*
|
||||
- simplexmq >= 3.0
|
||||
- socks == 0.6.*
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env sh
|
||||
# Safety measures
|
||||
[ -n "$1" ] || exit 1
|
||||
set -eu
|
||||
|
||||
tmp=$(mktemp -d -t)
|
||||
libsim=$(cat "$1" | grep libsimplex)
|
||||
libsup=$(cat "$1" | grep libsupport)
|
||||
commit="${2:-nix-android}"
|
||||
|
||||
# Clone simplex
|
||||
git clone https://github.com/simplex-chat/simplex-chat "$tmp/simplex-chat"
|
||||
|
||||
# Switch to nix-android branch
|
||||
git -C "$tmp/simplex-chat" checkout "$commit"
|
||||
|
||||
# Create missing folders
|
||||
mkdir -p "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
|
||||
curl -sSf "$libsim" -o "$tmp/libsimplex.zip"
|
||||
unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
|
||||
curl -sSf "$libsup" -o "$tmp/libsupport.zip"
|
||||
unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
|
||||
gradle -p "$tmp/simplex-chat/apps/android/" clean build
|
||||
cp "$tmp/simplex-chat/apps/android/app/build/outputs/apk/release/app-release-unsigned.apk" "$PWD/simplex-chat.apk"
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fail fast in case any command fails
|
||||
set -e
|
||||
|
||||
level=$1
|
||||
apk_parent_dir=$2
|
||||
sdk_dir=$3
|
||||
|
||||
store_file=$4
|
||||
store_password=$5
|
||||
key_alias=$6
|
||||
key_password=$7
|
||||
|
||||
if [ -z ${7} ]; then echo "You didn't enter all required params:
|
||||
compress-and-sign-apk.sh level apk_parent_dir sdk_dir store_file store_password key_alias key_password"
|
||||
fi
|
||||
|
||||
cd $apk_parent_dir
|
||||
|
||||
ORIG_NAME=$(echo app*.apk)
|
||||
unzip -o -q -d apk $ORIG_NAME
|
||||
|
||||
rm $ORIG_NAME
|
||||
|
||||
(cd apk && zip -r -q -$level ../$ORIG_NAME .)
|
||||
# Shouldn't be compressed because of Android requirement
|
||||
(cd apk && zip -r -q -0 ../$ORIG_NAME resources.arsc)
|
||||
#(cd apk && 7z a -r -mx=$level -tzip -x!resources.arsc ../$ORIG_NAME .)
|
||||
#(cd apk && 7z a -r -mx=0 -tzip ../$ORIG_NAME resources.arsc)
|
||||
|
||||
ALL_TOOLS=($sdk_dir/build-tools/*/)
|
||||
BIN_DIR="${ALL_TOOLS[1]}"
|
||||
|
||||
$BIN_DIR/zipalign -p -f 4 $ORIG_NAME $ORIG_NAME-2
|
||||
|
||||
mv $ORIG_NAME{-2,}
|
||||
|
||||
$BIN_DIR/apksigner sign \
|
||||
--ks "$store_file" --ks-key-alias "$key_alias" --ks-pass "pass:$store_password" \
|
||||
--key-pass "pass:$key_password" $ORIG_NAME
|
||||
|
||||
# cleanup
|
||||
rm -rf apk || true
|
||||
rm ${ORIG_NAME}.idsig 2> /dev/null || true
|
||||
@@ -0,0 +1,2 @@
|
||||
https://ci.zw3rk.com/build/494539/download/1/pkg-aarch64-android-libsimplex.zip
|
||||
https://ci.zw3rk.com/build/482524/download/1/pkg-aarch64-android-libsupport.zip
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."7d99c4b35cf2dc531219bc83146b714c9bae429c" = "037a0p7cdi4lrsbh21b4gldwdcj1sk8wz4wjsph6bnv2jyid11gk";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."a7b39b710c3aab9b2a38bd6841e52e0342b3a7ef" = "0iqk58dhckpij9l1z8bm83hghw5cwj9hmpkbk7j8vws123g1bd73";
|
||||
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
|
||||
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
|
||||
"https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
|
||||
|
||||
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 3.1.0
|
||||
version: 3.2.0
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -42,8 +42,12 @@ library
|
||||
Simplex.Chat.Migrations.M20220626_auto_reply
|
||||
Simplex.Chat.Migrations.M20220702_calls
|
||||
Simplex.Chat.Migrations.M20220715_groups_chat_item_id
|
||||
Simplex.Chat.Migrations.M20220811_chat_items_indices
|
||||
Simplex.Chat.Migrations.M20220812_incognito_profiles
|
||||
Simplex.Chat.Migrations.M20220818_chat_notifications
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Options
|
||||
Simplex.Chat.ProfileGenerator
|
||||
Simplex.Chat.Protocol
|
||||
Simplex.Chat.Store
|
||||
Simplex.Chat.Styled
|
||||
@@ -79,6 +83,7 @@ library
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
, random >=1.1 && <1.3
|
||||
, simple-logger ==0.1.*
|
||||
, simplexmq >=3.0
|
||||
, socks ==0.6.*
|
||||
@@ -119,6 +124,7 @@ executable simplex-bot
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
, random >=1.1 && <1.3
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=3.0
|
||||
@@ -160,6 +166,7 @@ executable simplex-bot-advanced
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
, random >=1.1 && <1.3
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=3.0
|
||||
@@ -202,6 +209,7 @@ executable simplex-chat
|
||||
, network ==3.1.*
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
, random >=1.1 && <1.3
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=3.0
|
||||
@@ -253,6 +261,7 @@ test-suite simplex-chat-test
|
||||
, network ==3.1.*
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
, random >=1.1 && <1.3
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=3.0
|
||||
|
||||
@@ -31,13 +31,12 @@ import Data.Either (fromRight)
|
||||
import Data.Fixed (div')
|
||||
import Data.Functor (($>))
|
||||
import Data.Int (Int64)
|
||||
import Data.List (find, isSuffixOf, sortBy, sortOn)
|
||||
import Data.List (find, isSuffixOf, sortOn)
|
||||
import Data.List.NonEmpty (NonEmpty, nonEmpty)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
|
||||
import Data.Ord (comparing)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
|
||||
@@ -51,6 +50,7 @@ import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.ProfileGenerator (generateRandomProfile)
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store
|
||||
import Simplex.Chat.Types
|
||||
@@ -99,19 +99,20 @@ defaultChatConfig =
|
||||
fileChunkSize = 15780,
|
||||
subscriptionConcurrency = 16,
|
||||
subscriptionEvents = False,
|
||||
hostEvents = False,
|
||||
testView = False
|
||||
}
|
||||
|
||||
_defaultSMPServers :: NonEmpty SMPServer
|
||||
_defaultSMPServers =
|
||||
L.fromList
|
||||
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im",
|
||||
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im",
|
||||
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im"
|
||||
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
|
||||
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion"
|
||||
]
|
||||
|
||||
_defaultNtfServers :: [NtfServer]
|
||||
_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im"]
|
||||
_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"]
|
||||
|
||||
maxImageSize :: Integer
|
||||
maxImageSize = 236700
|
||||
@@ -123,9 +124,9 @@ logCfg :: LogConfig
|
||||
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
|
||||
|
||||
newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
|
||||
newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, networkConfig, logConnections} sendToast = do
|
||||
newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
|
||||
let f = chatStoreFile dbFilePrefix
|
||||
config = cfg {subscriptionEvents = logConnections}
|
||||
config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts}
|
||||
sendNotification = fromMaybe (const $ pure ()) sendToast
|
||||
activeTo <- newTVarIO ActiveNone
|
||||
firstTime <- not <$> doesFileExist f
|
||||
@@ -142,8 +143,9 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de
|
||||
rcvFiles <- newTVarIO M.empty
|
||||
currentCalls <- atomically TM.empty
|
||||
filesFolder <- newTVarIO Nothing
|
||||
incognitoMode <- newTVarIO False
|
||||
chatStoreChanged <- newTVarIO False
|
||||
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder}
|
||||
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder}
|
||||
where
|
||||
resolveServers :: InitialAgentServers -> IO InitialAgentServers
|
||||
resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of
|
||||
@@ -232,13 +234,17 @@ processChatCommand = \case
|
||||
ff <- asks filesFolder
|
||||
atomically . writeTVar ff $ Just filesFolder'
|
||||
pure CRCmdOk
|
||||
SetIncognito onOff -> do
|
||||
incognito <- asks incognitoMode
|
||||
atomically . writeTVar incognito $ onOff
|
||||
pure CRCmdOk
|
||||
APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk
|
||||
APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk
|
||||
APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk
|
||||
APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore' $ \db -> getChatPreviews db user withPCC)
|
||||
APIGetChat (ChatRef cType cId) pagination -> withUser $ \user -> case cType of
|
||||
CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination)
|
||||
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination)
|
||||
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
|
||||
CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination search)
|
||||
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
|
||||
CTContactRequest -> pure $ chatCmdError "not implemented"
|
||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented"
|
||||
@@ -253,16 +259,14 @@ processChatCommand = \case
|
||||
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
|
||||
where
|
||||
setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd))
|
||||
setupSndFileTransfer ct = case file_ of
|
||||
Nothing -> pure Nothing
|
||||
Just file -> do
|
||||
(fileSize, chSize) <- checkSndFile file
|
||||
(agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
let fileName = takeFileName file
|
||||
fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}
|
||||
fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize
|
||||
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
|
||||
pure $ Just (fileInvitation, ciFile)
|
||||
setupSndFileTransfer ct = forM file_ $ \file -> do
|
||||
(fileSize, chSize) <- checkSndFile file
|
||||
(agentConnId, fileConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation
|
||||
let fileName = takeFileName file
|
||||
fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}
|
||||
fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize
|
||||
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
|
||||
pure (fileInvitation, ciFile)
|
||||
prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect))
|
||||
prepareMsg fileInvitation_ = case quotedItemId_ of
|
||||
Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing)
|
||||
@@ -290,15 +294,13 @@ processChatCommand = \case
|
||||
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
|
||||
where
|
||||
setupSndFileTransfer :: GroupInfo -> m (Maybe (FileInvitation, CIFile 'MDSnd))
|
||||
setupSndFileTransfer gInfo = case file_ of
|
||||
Nothing -> pure Nothing
|
||||
Just file -> do
|
||||
(fileSize, chSize) <- checkSndFile file
|
||||
let fileName = takeFileName file
|
||||
fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing}
|
||||
fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize
|
||||
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
|
||||
pure $ Just (fileInvitation, ciFile)
|
||||
setupSndFileTransfer gInfo = forM file_ $ \file -> do
|
||||
(fileSize, chSize) <- checkSndFile file
|
||||
let fileName = takeFileName file
|
||||
fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing}
|
||||
fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize
|
||||
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored}
|
||||
pure (fileInvitation, ciFile)
|
||||
prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup))
|
||||
prepareMsg fileInvitation_ membership = case quotedItemId_ of
|
||||
Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing)
|
||||
@@ -595,12 +597,35 @@ processChatCommand = \case
|
||||
pure CRCmdOk
|
||||
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
|
||||
APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig)
|
||||
APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user@User {userId} -> case cType of
|
||||
CTDirect -> do
|
||||
ct <- withStore $ \db -> do
|
||||
ct <- getContact db userId chatId
|
||||
liftIO $ updateContactSettings db user chatId chatSettings
|
||||
pure ct
|
||||
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings)
|
||||
pure CRCmdOk
|
||||
CTGroup -> do
|
||||
ms <- withStore $ \db -> do
|
||||
Group _ ms <- getGroup db user chatId
|
||||
liftIO $ updateGroupSettings db user chatId chatSettings
|
||||
pure ms
|
||||
forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId ->
|
||||
withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError)
|
||||
pure CRCmdOk
|
||||
_ -> pure $ chatCmdError "not supported"
|
||||
APIContactInfo contactId -> withUser $ \User {userId} -> do
|
||||
ct <- withStore $ \db -> getContact db userId contactId
|
||||
CRContactInfo ct <$> withAgent (`getConnectionServers` contactConnId ct)
|
||||
APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do
|
||||
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
|
||||
CRGroupMemberInfo g m <$> mapM (withAgent . flip getConnectionServers) (memberConnId m)
|
||||
-- [incognito] print user's incognito profile for this contact
|
||||
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
|
||||
pure $ CRContactInfo ct connectionStats incognitoProfile
|
||||
APIGroupMemberInfo gId gMemberId -> withUser $ \user@User {userId} -> do
|
||||
-- [incognito] print group member main profile
|
||||
(g, m@GroupMember {memberContactProfileId}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
|
||||
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
|
||||
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
|
||||
pure $ CRGroupMemberInfo g m connectionStats mainProfile
|
||||
ContactInfo cName -> withUser $ \User {userId} -> do
|
||||
contactId <- withStore $ \db -> getContactIdByName db userId cName
|
||||
processChatCommand $ APIContactInfo contactId
|
||||
@@ -610,20 +635,29 @@ processChatCommand = \case
|
||||
ChatHelp section -> pure $ CRChatHelp section
|
||||
Welcome -> withUser $ pure . CRWelcome
|
||||
AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do
|
||||
(connId, cReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew
|
||||
-- [incognito] generate profile for connection
|
||||
incognito <- readTVarIO =<< asks incognitoMode
|
||||
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
(connId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation
|
||||
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew incognitoProfile
|
||||
toView $ CRNewContactConnection conn
|
||||
pure $ CRInvitation cReq
|
||||
Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
|
||||
connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile
|
||||
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined
|
||||
-- [incognito] generate profile to send
|
||||
incognito <- readTVarIO =<< asks incognitoMode
|
||||
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
|
||||
connId <- withAgent $ \a -> joinConnection a True cReq . directMessage $ XInfo profileToSend
|
||||
conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined incognitoProfile
|
||||
toView $ CRNewContactConnection conn
|
||||
pure CRSentConfirmation
|
||||
Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} ->
|
||||
connectViaContact userId cReq profile
|
||||
-- [incognito] generate profile to send
|
||||
connectViaContact userId cReq $ fromLocalProfile profile
|
||||
Connect Nothing -> throwChatError CEInvalidConnReq
|
||||
ConnectSimplex -> withUser $ \User {userId, profile} ->
|
||||
connectViaContact userId adminContactReq profile
|
||||
-- [incognito] generate profile to send
|
||||
connectViaContact userId adminContactReq $ fromLocalProfile profile
|
||||
DeleteContact cName -> withUser $ \User {userId} -> do
|
||||
contactId <- withStore $ \db -> getContactIdByName db userId cName
|
||||
processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId)
|
||||
@@ -632,7 +666,7 @@ processChatCommand = \case
|
||||
processChatCommand $ APIClearChat (ChatRef CTDirect contactId)
|
||||
ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user)
|
||||
CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do
|
||||
(connId, cReq) <- withAgent (`createConnection` SCMContact)
|
||||
(connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact
|
||||
withStore $ \db -> createUserContactLink db userId connId cReq
|
||||
pure $ CRUserContactLinkCreated cReq
|
||||
DeleteMyAddress -> withUser $ \user -> withChatLock $ do
|
||||
@@ -685,46 +719,65 @@ processChatCommand = \case
|
||||
processChatCommand $ APIUpdateChatItem chatRef editedItemId mc
|
||||
NewGroup gProfile -> withUser $ \user -> do
|
||||
gVar <- asks idsDrg
|
||||
CRGroupCreated <$> withStore (\db -> createNewGroup db gVar user gProfile)
|
||||
-- [incognito] create membership with incognito profile
|
||||
incognito <- readTVarIO =<< asks incognitoMode
|
||||
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile incognitoProfile)
|
||||
pure $ CRGroupCreated groupInfo incognitoProfile
|
||||
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock $ do
|
||||
-- TODO for large groups: no need to load all members to determine if contact is a member
|
||||
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId
|
||||
-- [incognito] forbid to invite contact to whom user is connected as incognito if user's membership is not incognito
|
||||
let Group gInfo@GroupInfo {localDisplayName, groupProfile, membership} members = group
|
||||
GroupMember {memberRole = userRole, memberId = userMemberId} = membership
|
||||
Contact {localDisplayName = cName} = contact
|
||||
when (contactConnIncognito contact && not (memberIncognito membership)) $ throwChatError CEGroupNotIncognitoCantInvite
|
||||
when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole
|
||||
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo)
|
||||
unless (memberActive membership) $ throwChatError CEGroupMemberNotActive
|
||||
let sendInvitation groupMemberId memberId cReq = do
|
||||
let groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile
|
||||
let sendInvitation member@GroupMember {groupMemberId, memberId} cReq = do
|
||||
-- [incognito] if membership is incognito, send its incognito profile in GroupInvitation
|
||||
let incognitoProfile = if memberIncognito membership then Just (fromLocalProfile $ memberProfile membership) else Nothing
|
||||
groupInv = GroupInvitation (MemberIdRole userMemberId userRole) incognitoProfile (MemberIdRole memberId memRole) cReq groupProfile
|
||||
msg <- sendDirectContactMessage contact $ XGrpInv groupInv
|
||||
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
|
||||
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, invitedIncognito = Just $ memberIncognito membership}) memRole
|
||||
ci <- saveSndChatItem user (CDDirectSnd contact) msg content Nothing Nothing
|
||||
toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci
|
||||
setActive $ ActiveG localDisplayName
|
||||
pure $ CRSentGroupInvitation gInfo contact
|
||||
pure $ CRSentGroupInvitation gInfo contact member incognitoProfile
|
||||
case contactMember contact members of
|
||||
Nothing -> do
|
||||
gVar <- asks idsDrg
|
||||
(agentConnId, cReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
GroupMember {memberId, groupMemberId} <- withStore $ \db -> createContactMember db gVar user groupId contact memRole agentConnId cReq
|
||||
sendInvitation groupMemberId memberId cReq
|
||||
Just GroupMember {groupMemberId, memberId, memberStatus}
|
||||
(agentConnId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation
|
||||
member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq
|
||||
sendInvitation member cReq
|
||||
Just member@GroupMember {groupMemberId, memberStatus}
|
||||
| memberStatus == GSMemInvited ->
|
||||
withStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case
|
||||
Just cReq -> sendInvitation groupMemberId memberId cReq
|
||||
Just cReq -> sendInvitation member cReq
|
||||
Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||
| otherwise -> throwChatError $ CEGroupDuplicateMember cName
|
||||
APIJoinGroup groupId -> withUser $ \user@User {userId} -> do
|
||||
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId
|
||||
withChatLock . procCmd $ do
|
||||
agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership :: GroupMember)
|
||||
-- [incognito] if incognito mode is enabled [AND membership is not incognito] update membership to use incognito profile
|
||||
incognito <- readTVarIO =<< asks incognitoMode
|
||||
g'@GroupInfo {membership = membership'} <-
|
||||
if incognito && not (memberIncognito membership)
|
||||
then do
|
||||
incognitoProfile <- liftIO generateRandomProfile
|
||||
membership' <- withStore $ \db -> createMemberIncognitoProfile db userId membership (Just incognitoProfile)
|
||||
pure g {membership = membership'}
|
||||
else pure g
|
||||
-- [incognito] if membership is incognito, send its incognito profile in XGrpAcpt
|
||||
let incognitoProfile = if memberIncognito membership' then Just (fromLocalProfile $ memberProfile membership') else Nothing
|
||||
agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership' :: GroupMember)) incognitoProfile
|
||||
withStore' $ \db -> do
|
||||
createMemberConnection db userId fromMember agentConnId
|
||||
updateGroupMemberStatus db userId fromMember GSMemAccepted
|
||||
updateGroupMemberStatus db userId membership GSMemAccepted
|
||||
updateGroupMemberStatus db userId membership' GSMemAccepted
|
||||
updateCIGroupInvitationStatus user
|
||||
pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}}
|
||||
pure $ CRUserAcceptedGroupSent g' {membership = membership' {memberStatus = GSMemAccepted}}
|
||||
where
|
||||
updateCIGroupInvitationStatus user@User {userId} = do
|
||||
AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId
|
||||
@@ -749,7 +802,7 @@ processChatCommand = \case
|
||||
withStore' $ \db -> deleteGroupMember db user m
|
||||
_ -> do
|
||||
msg <- sendGroupMessage gInfo members $ XGrpMemDel mId
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId memberProfile) Nothing Nothing
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) Nothing Nothing
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
|
||||
deleteMemberConnection m
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemRemoved
|
||||
@@ -812,7 +865,7 @@ processChatCommand = \case
|
||||
processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||
LastMessages (Just chatName) count -> withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count)
|
||||
CRLastMessages . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) Nothing)
|
||||
LastMessages Nothing count -> withUser $ \user -> withStore $ \db ->
|
||||
CRLastMessages <$> getAllChatItems db user (CPLast count)
|
||||
SendFile chatName f -> withUser $ \user -> do
|
||||
@@ -860,12 +913,12 @@ processChatCommand = \case
|
||||
pure $ CRRcvFileCancelled ftr
|
||||
FileStatus fileId ->
|
||||
CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId)
|
||||
ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile
|
||||
ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile (fromLocalProfile profile)
|
||||
UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
|
||||
let p = (profile :: Profile) {displayName = displayName, fullName = fullName}
|
||||
let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName}
|
||||
updateProfile user p
|
||||
UpdateProfileImage image -> withUser $ \user@User {profile} -> do
|
||||
let p = (profile :: Profile) {image}
|
||||
let p = (fromLocalProfile profile :: Profile) {image}
|
||||
updateProfile user p
|
||||
QuitChat -> liftIO exitSuccess
|
||||
ShowVersion -> pure $ CRVersionInfo versionNumber
|
||||
@@ -909,10 +962,18 @@ processChatCommand = \case
|
||||
(_, xContactId_) -> procCmd $ do
|
||||
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
xContactId <- maybe randomXContactId pure xContactId_
|
||||
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId)
|
||||
conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId
|
||||
-- [incognito] generate profile to send
|
||||
-- if user makes a contact request using main profile, then turns on incognito mode and repeats the request,
|
||||
-- an incognito profile will be sent even though the address holder will have user's main profile received as well;
|
||||
-- we ignore this edge case as we already allow profile updates on repeat contact requests;
|
||||
-- alternatively we can re-send the main profile even if incognito mode is enabled
|
||||
incognito <- readTVarIO =<< asks incognitoMode
|
||||
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
let profileToSend = fromMaybe profile incognitoProfile
|
||||
connId <- withAgent $ \a -> joinConnection a True cReq $ directMessage (XContact profileToSend $ Just xContactId)
|
||||
conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile
|
||||
toView $ CRNewContactConnection conn
|
||||
pure CRSentInvitation
|
||||
pure $ CRSentInvitation incognitoProfile
|
||||
contactMember :: Contact -> [GroupMember] -> Maybe GroupMember
|
||||
contactMember Contact {contactId} =
|
||||
find $ \GroupMember {memberContactId = cId, memberStatus = s} ->
|
||||
@@ -923,17 +984,20 @@ processChatCommand = \case
|
||||
unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f
|
||||
(,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config)
|
||||
updateProfile :: User -> Profile -> m ChatResponse
|
||||
updateProfile user@User {profile = p} p'@Profile {displayName}
|
||||
| p' == p = pure CRUserProfileNoChange
|
||||
updateProfile user@User {profile = p@LocalProfile {profileId}} p'@Profile {displayName}
|
||||
| p' == fromLocalProfile p = pure CRUserProfileNoChange
|
||||
| otherwise = do
|
||||
withStore $ \db -> updateUserProfile db user p'
|
||||
let user' = (user :: User) {localDisplayName = displayName, profile = p'}
|
||||
let user' = (user :: User) {localDisplayName = displayName, profile = toLocalProfile profileId p'}
|
||||
asks currentUser >>= atomically . (`writeTVar` Just user')
|
||||
contacts <- filter isReady <$> withStore' (`getUserContacts` user)
|
||||
-- [incognito] filter out contacts with whom user has incognito connections
|
||||
contacts <-
|
||||
filter (\ct -> isReady ct && not (contactConnIncognito ct))
|
||||
<$> withStore' (`getUserContacts` user)
|
||||
withChatLock . procCmd $ do
|
||||
forM_ contacts $ \ct ->
|
||||
void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError)
|
||||
pure $ CRUserProfileUpdated p p'
|
||||
pure $ CRUserProfileUpdated (fromLocalProfile p) p'
|
||||
isReady :: Contact -> Bool
|
||||
isReady ct =
|
||||
let s = connStatus $ activeConn (ct :: Contact)
|
||||
@@ -1055,7 +1119,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
|
||||
case fileConnReq of
|
||||
-- direct file protocol
|
||||
Just connReq ->
|
||||
tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case
|
||||
tryError (withAgent $ \a -> joinConnection a True connReq . directMessage $ XFileAcpt fName) >>= \case
|
||||
Right agentConnId -> do
|
||||
filePath <- getRcvFilePath filePath_ fName
|
||||
withStore $ \db -> acceptRcvFileTransfer db user fileId agentConnId ConnJoined filePath
|
||||
@@ -1069,7 +1133,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
|
||||
case activeConn of
|
||||
Just conn -> do
|
||||
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
|
||||
(agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
(agentConnId, fileInvConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation
|
||||
filePath <- getRcvFilePath filePath_ fName
|
||||
ci <- withStore $ \db -> acceptRcvFileTransfer db user fileId agentConnId ConnNew filePath
|
||||
void $ sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId)
|
||||
@@ -1116,8 +1180,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
|
||||
|
||||
acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact
|
||||
acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} = do
|
||||
connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile
|
||||
withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId
|
||||
-- [incognito] generate profile to send, create connection with incognito profile
|
||||
incognito <- readTVarIO =<< asks incognitoMode
|
||||
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
|
||||
connId <- withAgent $ \a -> acceptContact a True invId . directMessage $ XInfo profileToSend
|
||||
withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId incognitoProfile
|
||||
|
||||
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
agentSubscriber = do
|
||||
@@ -1191,7 +1259,7 @@ subscribeUserConnections agentBatchSubscribe user = do
|
||||
groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m ()
|
||||
groupSubsToView rs gs ms ce = do
|
||||
mapM_ groupSub $
|
||||
sortBy (comparing $ \(Group GroupInfo {localDisplayName = g} _) -> g) gs
|
||||
sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs
|
||||
toView . CRMemberSubSummary $ map (uncurry MemberSubStatus) mRs
|
||||
where
|
||||
mRs = resultsFor rs ms
|
||||
@@ -1247,15 +1315,18 @@ subscribeUserConnections agentBatchSubscribe user = do
|
||||
processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m ()
|
||||
processAgentMessage Nothing _ _ = throwChatError CENoActiveUser
|
||||
processAgentMessage (Just User {userId}) "" agentMessage = case agentMessage of
|
||||
CONNECT p h -> hostEvent $ CRHostConnected p h
|
||||
DISCONNECT p h -> hostEvent $ CRHostDisconnected p h
|
||||
DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected"
|
||||
UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected"
|
||||
SUSPENDED -> toView CRChatSuspended
|
||||
_ -> pure ()
|
||||
where
|
||||
serverEvent srv@(SMPServer host port _) conns event str = do
|
||||
hostEvent = whenM (asks $ hostEvents . config) . toView
|
||||
serverEvent srv@(SMPServer host _ _) conns event str = do
|
||||
cs <- withStore' $ \db -> getConnectionsContacts db userId conns
|
||||
toView $ event srv cs
|
||||
showToast ("server " <> str) (safeDecodeUtf8 . strEncode $ SrvLoc host port)
|
||||
showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host)
|
||||
processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage =
|
||||
(withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus) >>= \case
|
||||
RcvDirectMsgConnection conn contact_ ->
|
||||
@@ -1289,11 +1360,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
_ -> Nothing
|
||||
|
||||
processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m ()
|
||||
processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink} = \case
|
||||
processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink, customUserProfileId} = \case
|
||||
Nothing -> case agentMsg of
|
||||
CONF confId _ connInfo -> do
|
||||
-- [incognito] send saved profile
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
|
||||
saveConnInfo conn connInfo
|
||||
allowAgentConnection conn confId $ XInfo profile
|
||||
allowAgentConnection conn confId $ XInfo profileToSend
|
||||
INFO connInfo ->
|
||||
saveConnInfo conn connInfo
|
||||
MSG meta _msgFlags msgBody -> do
|
||||
@@ -1354,7 +1428,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
CON ->
|
||||
withStore' (\db -> getViaGroupMember db user ct) >>= \case
|
||||
Nothing -> do
|
||||
toView $ CRContactConnected ct
|
||||
-- [incognito] print incognito profile used for this contact
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||
toView $ CRContactConnected ct incognitoProfile
|
||||
setActive $ ActiveC c
|
||||
showToast (c <> "> ") "connected"
|
||||
forM_ viaUserContactLink $ \userContactLinkId -> do
|
||||
@@ -1382,25 +1458,26 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
-- TODO print errors
|
||||
MERR msgId err -> do
|
||||
chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId
|
||||
case chatItemId_ of
|
||||
Nothing -> pure ()
|
||||
Just chatItemId -> do
|
||||
chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err)
|
||||
toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
|
||||
forM_ chatItemId_ $ \chatItemId -> do
|
||||
chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err)
|
||||
toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
|
||||
ERR err -> toView . CRChatError $ ChatErrorAgent err
|
||||
-- TODO add debugging output
|
||||
_ -> pure ()
|
||||
|
||||
processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m ()
|
||||
processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership} m = case agentMsg of
|
||||
processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership, chatSettings} m@GroupMember {memberContactProfileId} = case agentMsg of
|
||||
CONF confId _ connInfo -> do
|
||||
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
|
||||
case memberCategory m of
|
||||
GCInviteeMember ->
|
||||
case chatMsgEvent of
|
||||
XGrpAcpt memId
|
||||
XGrpAcpt memId incognitoProfile
|
||||
| sameMemberId memId m -> do
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemAccepted
|
||||
-- [incognito] update member profile to incognito profile
|
||||
withStore $ \db -> do
|
||||
liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
|
||||
void $ createMemberIncognitoProfile db userId m incognitoProfile
|
||||
allowAgentConnection conn confId XOk
|
||||
| otherwise -> messageError "x.grp.acpt: memberId is different from expected"
|
||||
_ -> messageError "CONF from invited member must have x.grp.acpt"
|
||||
@@ -1409,7 +1486,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
XGrpMemInfo memId _memProfile
|
||||
| sameMemberId memId m -> do
|
||||
-- TODO update member profile
|
||||
allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) profile
|
||||
-- [incognito] send membership incognito profile
|
||||
allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
|
||||
| otherwise -> messageError "x.grp.mem.info: memberId is different from expected"
|
||||
_ -> messageError "CONF from member must have x.grp.mem.info"
|
||||
INFO connInfo -> do
|
||||
@@ -1430,15 +1508,20 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
unless (memberActive membership) $
|
||||
updateGroupMemberStatus db userId membership GSMemConnected
|
||||
sendPendingGroupMessages m conn
|
||||
unless (enableNtfs chatSettings) . withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) False
|
||||
case memberCategory m of
|
||||
GCHostMember -> do
|
||||
memberConnectedChatItem gInfo m
|
||||
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected}
|
||||
-- [incognito] chat item & event with indication that host connected incognito
|
||||
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
|
||||
memberConnectedChatItem gInfo m mainProfile
|
||||
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} (memberIncognito membership)
|
||||
setActive $ ActiveG gName
|
||||
showToast ("#" <> gName) "you are connected to group"
|
||||
GCInviteeMember -> do
|
||||
memberConnectedChatItem gInfo m
|
||||
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected}
|
||||
-- [incognito] chat item & event with indication that invitee connected incognito
|
||||
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
|
||||
memberConnectedChatItem gInfo m mainProfile
|
||||
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} mainProfile
|
||||
setActive $ ActiveG gName
|
||||
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
|
||||
intros <- withStore' $ \db -> createIntroductions db members m
|
||||
@@ -1625,10 +1708,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
cancelRcvFileTransfer user ft
|
||||
throwChatError $ CEFileRcvChunk err
|
||||
|
||||
memberConnectedChatItem :: GroupInfo -> GroupMember -> m ()
|
||||
memberConnectedChatItem gInfo m = do
|
||||
memberConnectedChatItem :: GroupInfo -> GroupMember -> Maybe Profile -> m ()
|
||||
memberConnectedChatItem gInfo m mainProfile_ = do
|
||||
createdAt <- liftIO getCurrentTime
|
||||
let content = CIRcvGroupEvent RGEMemberConnected
|
||||
let content = CIRcvGroupEvent $ case mainProfile_ of
|
||||
Just mainProfile -> RGEMemberConnected $ Just mainProfile
|
||||
_ -> RGEMemberConnected Nothing
|
||||
cd = CDGroupRcv gInfo m
|
||||
-- first ts should be broker ts but we don't have it for CON
|
||||
ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content createdAt createdAt
|
||||
@@ -1637,7 +1722,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
|
||||
notifyMemberConnected :: GroupInfo -> GroupMember -> m ()
|
||||
notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do
|
||||
memberConnectedChatItem gInfo m
|
||||
memberConnectedChatItem gInfo m Nothing
|
||||
toView $ CRConnectedToGroupMember gInfo m
|
||||
let g = groupName' gInfo
|
||||
setActive $ ActiveG g
|
||||
@@ -1675,13 +1760,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
setActive $ ActiveC c
|
||||
|
||||
processFileInvitation :: Maybe FileInvitation -> (FileInvitation -> Integer -> m RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv))
|
||||
processFileInvitation fileInvitation_ createRcvFileTransferF = case fileInvitation_ of
|
||||
Nothing -> pure Nothing
|
||||
Just fileInvitation@FileInvitation {fileName, fileSize} -> do
|
||||
processFileInvitation fileInvitation_ createRcvFileTransferF =
|
||||
forM fileInvitation_ $ \fileInvitation@FileInvitation {fileName, fileSize} -> do
|
||||
chSize <- asks $ fileChunkSize . config
|
||||
RcvFileTransfer {fileId} <- createRcvFileTransferF fileInvitation chSize
|
||||
let ciFile = CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation}
|
||||
pure $ Just ciFile
|
||||
pure ciFile
|
||||
|
||||
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m ()
|
||||
messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = do
|
||||
@@ -1814,7 +1898,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
unless cancelled $
|
||||
if fName == fileName
|
||||
then
|
||||
tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case
|
||||
tryError (withAgent $ \a -> joinConnection a True fileConnReq . directMessage $ XOk) >>= \case
|
||||
Right acId ->
|
||||
withStore' $ \db -> createSndGroupFileTransferConnection db userId fileId acId m
|
||||
Left e -> throwError e
|
||||
@@ -1826,16 +1910,20 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
|
||||
|
||||
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
|
||||
processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) msg msgMeta = do
|
||||
processGroupInvitation ct@Contact {contactId, localDisplayName = c} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), fromMemberProfile, invitedMember = (MemberIdRole memId memRole)} msg msgMeta = do
|
||||
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
||||
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
|
||||
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
|
||||
gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv
|
||||
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
|
||||
-- [incognito] if (received group invitation has host's incognito profile OR direct connection with host is incognito), create membership with new incognito profile; incognito mode is checked when joining group
|
||||
hostContact <- withStore $ \db -> getContact db userId contactId
|
||||
let joinGroupIncognito = isJust fromMemberProfile || contactConnIncognito hostContact
|
||||
incognitoProfile <- if joinGroupIncognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv incognitoProfile
|
||||
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, invitedIncognito = Just joinGroupIncognito}) memRole
|
||||
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing
|
||||
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
|
||||
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
|
||||
toView $ CRReceivedGroupInvitation gInfo ct memRole
|
||||
toView $ CRReceivedGroupInvitation gInfo ct memRole fromMemberProfile
|
||||
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
|
||||
|
||||
checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m ()
|
||||
@@ -1853,23 +1941,27 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
toView $ CRNewChatItem $ AChatItem (chatTypeI @c) SMDRcv (toChatInfo cd) ci
|
||||
|
||||
xInfo :: Contact -> Profile -> m ()
|
||||
xInfo c@Contact {profile = p} p' = unless (p == p') $ do
|
||||
xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
|
||||
c' <- withStore $ \db -> updateContactProfile db userId c p'
|
||||
toView $ CRContactUpdated c c'
|
||||
|
||||
xInfoProbe :: Contact -> Probe -> m ()
|
||||
xInfoProbe c2 probe = do
|
||||
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe
|
||||
forM_ r $ \c1 -> probeMatch c1 c2 probe
|
||||
xInfoProbe c2 probe =
|
||||
-- [incognito] unless connected incognito
|
||||
unless (contactConnIncognito c2) $ do
|
||||
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe
|
||||
forM_ r $ \c1 -> probeMatch c1 c2 probe
|
||||
|
||||
xInfoProbeCheck :: Contact -> ProbeHash -> m ()
|
||||
xInfoProbeCheck c1 probeHash = do
|
||||
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash
|
||||
forM_ r . uncurry $ probeMatch c1
|
||||
xInfoProbeCheck c1 probeHash =
|
||||
-- [incognito] unless connected incognito
|
||||
unless (contactConnIncognito c1) $ do
|
||||
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash
|
||||
forM_ r . uncurry $ probeMatch c1
|
||||
|
||||
probeMatch :: Contact -> Contact -> Probe -> m ()
|
||||
probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe =
|
||||
when (p1 == p2) $ do
|
||||
when (fromLocalProfile p1 == fromLocalProfile p2) $ do
|
||||
void . sendDirectContactMessage c1 $ XInfoProbeOk probe
|
||||
mergeContacts c1 c2
|
||||
|
||||
@@ -2005,16 +2097,18 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
toView $ CRJoinedGroupMemberConnecting gInfo m newMember
|
||||
|
||||
xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m ()
|
||||
xGrpMemIntro conn gInfo@GroupInfo {groupId} m memInfo@(MemberInfo memId _ _) = do
|
||||
xGrpMemIntro conn gInfo@GroupInfo {groupId, membership} m memInfo@(MemberInfo memId _ _) = do
|
||||
case memberCategory m of
|
||||
GCHostMember -> do
|
||||
members <- withStore' $ \db -> getGroupMembers db user gInfo
|
||||
if isMember memId gInfo members
|
||||
then messageWarning "x.grp.mem.intro ignored: member already exists"
|
||||
else do
|
||||
(groupConnId, groupConnReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
(directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId
|
||||
(groupConnId, groupConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation
|
||||
(directConnId, directConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation
|
||||
-- [incognito] direct connection with member has to be established using same incognito profile
|
||||
customUserProfileId <- if memberIncognito membership then Just <$> withStore (\db -> getGroupMemberProfileId db userId membership) else pure Nothing
|
||||
newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId customUserProfileId
|
||||
let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq}
|
||||
void $ sendDirectMessage conn msg (GroupId groupId)
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId newMember GSMemIntroInvited
|
||||
@@ -2043,10 +2137,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced
|
||||
Just m' -> pure m'
|
||||
withStore' $ \db -> saveMemberInvitation db toMember introInv
|
||||
let msg = XGrpMemInfo (memberId (membership :: GroupMember)) profile
|
||||
groupConnId <- withAgent $ \a -> joinConnection a groupConnReq $ directMessage msg
|
||||
directConnId <- withAgent $ \a -> joinConnection a directConnReq $ directMessage msg
|
||||
withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId
|
||||
-- [incognito] send membership incognito profile, create direct connection as incognito
|
||||
let msg = XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
|
||||
groupConnId <- withAgent $ \a -> joinConnection a True groupConnReq $ directMessage msg
|
||||
directConnId <- withAgent $ \a -> joinConnection a True directConnReq $ directMessage msg
|
||||
customUserProfileId <- if memberIncognito membership then Just <$> withStore (\db -> getGroupMemberProfileId db userId membership) else pure Nothing
|
||||
withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId customUserProfileId
|
||||
|
||||
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m ()
|
||||
xGrpMemDel gInfo@GroupInfo {membership} m memId msg msgMeta = do
|
||||
@@ -2067,7 +2163,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
|
||||
else do
|
||||
deleteMemberConnection member
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId member GSMemRemoved
|
||||
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberDeleted groupMemberId memberProfile) Nothing
|
||||
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)) Nothing
|
||||
groupMsgToView gInfo m ci msgMeta
|
||||
toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved}
|
||||
|
||||
@@ -2384,7 +2480,7 @@ getCreateActiveUser st = do
|
||||
withTransaction st (`setActiveUser` userId user)
|
||||
pure user
|
||||
userStr :: User -> String
|
||||
userStr User {localDisplayName, profile = Profile {fullName}} =
|
||||
userStr User {localDisplayName, profile = LocalProfile {fullName}} =
|
||||
T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")"
|
||||
getContactName :: IO ContactName
|
||||
getContactName = do
|
||||
@@ -2459,7 +2555,7 @@ chatCommandP =
|
||||
"/_db import " *> (APIImportArchive <$> jsonP),
|
||||
"/_db delete" $> APIDeleteStorage,
|
||||
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
|
||||
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP),
|
||||
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP),
|
||||
"/_get items count=" *> (APIGetChatItems <$> A.decimal),
|
||||
"/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
|
||||
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP),
|
||||
@@ -2496,6 +2592,7 @@ chatCommandP =
|
||||
"/_network " *> (APISetNetworkConfig <$> jsonP),
|
||||
("/network " <|> "/net ") *> (APISetNetworkConfig <$> netCfgP),
|
||||
("/network" <|> "/net") $> APIGetNetworkConfig,
|
||||
"/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP),
|
||||
"/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_info @" *> (APIContactInfo <$> A.decimal),
|
||||
("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* optional (A.char '@') <*> displayName),
|
||||
@@ -2525,7 +2622,7 @@ chatCommandP =
|
||||
("/contacts" <|> "/cs") $> ListContacts,
|
||||
("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
|
||||
("/connect" <|> "/c") $> AddContact,
|
||||
(SendMessage <$> chatNameP <* A.space <*> A.takeByteString),
|
||||
SendMessage <$> chatNameP <* A.space <*> A.takeByteString,
|
||||
(">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv),
|
||||
(">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd),
|
||||
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString),
|
||||
@@ -2552,6 +2649,7 @@ chatCommandP =
|
||||
"/profile_image" $> UpdateProfileImage Nothing,
|
||||
("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames),
|
||||
("/profile" <|> "/p") $> ShowProfile,
|
||||
"/incognito " *> (SetIncognito <$> onOffP),
|
||||
("/quit" <|> "/q" <|> "/exit") $> QuitChat,
|
||||
("/version" <|> "/v") $> ShowVersion
|
||||
]
|
||||
@@ -2588,6 +2686,7 @@ chatCommandP =
|
||||
n <- (A.space *> A.takeByteString) <|> pure ""
|
||||
pure $ if B.null n then name else safeDecodeUtf8 n
|
||||
filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
||||
searchP = T.unpack . safeDecodeUtf8 <$> (" search=" *> A.takeByteString)
|
||||
memberRole =
|
||||
(" owner" $> GROwner)
|
||||
<|> (" admin" $> GRAdmin)
|
||||
|
||||
@@ -24,7 +24,7 @@ chatBotRepl welcome answer _user cc = do
|
||||
race_ (forever $ void getLine) . forever $ do
|
||||
(_, resp) <- atomically . readTBQueue $ outputQ cc
|
||||
case resp of
|
||||
CRContactConnected contact -> do
|
||||
CRContactConnected contact _ -> do
|
||||
contactConnected contact
|
||||
void $ sendMsg contact welcome
|
||||
CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do
|
||||
@@ -40,7 +40,7 @@ initializeBotAddress cc = do
|
||||
sendChatCmd cc "/show_address" >>= \case
|
||||
CRUserContactLink uri _ _ -> showBotAddress uri
|
||||
CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do
|
||||
putStrLn $ "No bot address, creating..."
|
||||
putStrLn "No bot address, creating..."
|
||||
sendChatCmd cc "/address" >>= \case
|
||||
CRUserContactLinkCreated uri -> showBotAddress uri
|
||||
_ -> putStrLn "can't create bot address" >> exitFailure
|
||||
|
||||
@@ -40,8 +40,9 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
|
||||
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (CorrId, MsgFlags)
|
||||
import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags)
|
||||
import Simplex.Messaging.TMap (TMap)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost)
|
||||
import System.IO (Handle)
|
||||
import UnliftIO.STM
|
||||
|
||||
@@ -62,6 +63,7 @@ data ChatConfig = ChatConfig
|
||||
fileChunkSize :: Integer,
|
||||
subscriptionConcurrency :: Int,
|
||||
subscriptionEvents :: Bool,
|
||||
hostEvents :: Bool,
|
||||
testView :: Bool
|
||||
}
|
||||
|
||||
@@ -86,7 +88,8 @@ data ChatController = ChatController
|
||||
rcvFiles :: TVar (Map Int64 Handle),
|
||||
currentCalls :: TMap ContactId Call,
|
||||
config :: ChatConfig,
|
||||
filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps
|
||||
filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps,
|
||||
incognitoMode :: TVar Bool
|
||||
}
|
||||
|
||||
data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages | HSSettings
|
||||
@@ -105,11 +108,12 @@ data ChatCommand
|
||||
| APISuspendChat {suspendTimeout :: Int}
|
||||
| ResubscribeAllConnections
|
||||
| SetFilesFolder FilePath
|
||||
| SetIncognito Bool
|
||||
| APIExportArchive ArchiveConfig
|
||||
| APIImportArchive ArchiveConfig
|
||||
| APIDeleteStorage
|
||||
| APIGetChats {pendingConnections :: Bool}
|
||||
| APIGetChat ChatRef ChatPagination
|
||||
| APIGetChat ChatRef ChatPagination (Maybe String)
|
||||
| APIGetChatItems Int
|
||||
| APISendMessage ChatRef ComposedMessage
|
||||
| APIUpdateChatItem ChatRef ChatItemId MsgContent
|
||||
@@ -146,6 +150,7 @@ data ChatCommand
|
||||
| SetUserSMPServers [SMPServer]
|
||||
| APISetNetworkConfig NetworkConfig
|
||||
| APIGetNetworkConfig
|
||||
| APISetChatSettings ChatRef ChatSettings
|
||||
| APIContactInfo ContactId
|
||||
| APIGroupMemberInfo GroupId GroupMemberId
|
||||
| ContactInfo ContactName
|
||||
@@ -208,8 +213,8 @@ data ChatResponse
|
||||
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
|
||||
| CRUserSMPServers {smpServers :: [SMPServer]}
|
||||
| CRNetworkConfig {networkConfig :: NetworkConfig}
|
||||
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats}
|
||||
| CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats}
|
||||
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
|
||||
| CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats, mainProfile :: Maybe Profile}
|
||||
| CRNewChatItem {chatItem :: AChatItem}
|
||||
| CRChatItemStatusUpdated {chatItem :: AChatItem}
|
||||
| CRChatItemUpdated {chatItem :: AChatItem}
|
||||
@@ -221,7 +226,7 @@ data ChatResponse
|
||||
| CRCmdOk
|
||||
| CRChatHelp {helpSection :: HelpSection}
|
||||
| CRWelcome {user :: User}
|
||||
| CRGroupCreated {groupInfo :: GroupInfo}
|
||||
| CRGroupCreated {groupInfo :: GroupInfo, customUserProfile :: Maybe Profile}
|
||||
| CRGroupMembers {group :: Group}
|
||||
| CRContactsList {contacts :: [Contact]}
|
||||
| CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent}
|
||||
@@ -230,14 +235,14 @@ data ChatResponse
|
||||
| CRUserAcceptedGroupSent {groupInfo :: GroupInfo}
|
||||
| CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRGroupsList {groups :: [GroupInfo]}
|
||||
| CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact}
|
||||
| CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember, sentCustomProfile :: Maybe Profile}
|
||||
| CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
|
||||
| CRUserProfile {profile :: Profile}
|
||||
| CRUserProfileNoChange
|
||||
| CRVersionInfo {version :: String}
|
||||
| CRInvitation {connReqInvitation :: ConnReqInvitation}
|
||||
| CRSentConfirmation
|
||||
| CRSentInvitation
|
||||
| CRSentInvitation {customUserProfile :: Maybe Profile}
|
||||
| CRContactUpdated {fromContact :: Contact, toContact :: Contact}
|
||||
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
|
||||
| CRContactDeleted {contact :: Contact}
|
||||
@@ -263,16 +268,18 @@ data ChatResponse
|
||||
| CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
|
||||
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
|
||||
| CRContactConnecting {contact :: Contact}
|
||||
| CRContactConnected {contact :: Contact}
|
||||
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
|
||||
| CRContactAnotherClient {contact :: Contact}
|
||||
| CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]}
|
||||
| CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]}
|
||||
| CRContactSubError {contact :: Contact, chatError :: ChatError}
|
||||
| CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]}
|
||||
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
|
||||
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
|
||||
| CRGroupInvitation {groupInfo :: GroupInfo}
|
||||
| CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole}
|
||||
| CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember}
|
||||
| CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole, receivedCustomProfile :: Maybe Profile}
|
||||
| CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember, usedCustomProfile :: Bool}
|
||||
| CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember, mainProfile :: Maybe Profile}
|
||||
| CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember}
|
||||
| CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember}
|
||||
@@ -379,6 +386,7 @@ data ChatErrorType
|
||||
| CEContactNotReady {contact :: Contact}
|
||||
| CEContactGroups {contact :: Contact, groupNames :: [GroupName]}
|
||||
| CEGroupUserRole
|
||||
| CEGroupNotIncognitoCantInvite
|
||||
| CEGroupContactRole {contactName :: ContactName}
|
||||
| CEGroupDuplicateMember {contactName :: ContactName}
|
||||
| CEGroupDuplicateMemberId
|
||||
|
||||
@@ -18,7 +18,7 @@ import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Styled
|
||||
import Simplex.Chat.Types (Profile (..), User (..))
|
||||
import Simplex.Chat.Types (User (..), LocalProfile (..))
|
||||
import System.Console.ANSI.Types
|
||||
|
||||
highlight :: Text -> Markdown
|
||||
@@ -55,7 +55,7 @@ chatWelcome user =
|
||||
"Type " <> highlight "/help" <> " for usage info, " <> highlight "/welcome" <> " to show this message"
|
||||
]
|
||||
where
|
||||
User {profile = Profile {displayName, fullName}} = user
|
||||
User {profile = LocalProfile {displayName, fullName}} = user
|
||||
userName = if T.null fullName then displayName else fullName
|
||||
|
||||
chatHelpInfo :: [StyledString]
|
||||
|
||||
@@ -501,22 +501,24 @@ ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayN
|
||||
|
||||
rcvGroupEventToText :: RcvGroupEvent -> Text
|
||||
rcvGroupEventToText = \case
|
||||
RGEMemberAdded _ p -> "added " <> memberProfileToText p
|
||||
RGEMemberConnected -> "connected"
|
||||
RGEMemberAdded _ p -> "added " <> profileToText p
|
||||
RGEMemberConnected contactMainProfile -> case contactMainProfile of
|
||||
Just p -> profileToText p <> " connected incognito"
|
||||
Nothing -> "connected"
|
||||
RGEMemberLeft -> "left"
|
||||
RGEMemberDeleted _ p -> "removed " <> memberProfileToText p
|
||||
RGEMemberDeleted _ p -> "removed " <> profileToText p
|
||||
RGEUserDeleted -> "removed you"
|
||||
RGEGroupDeleted -> "deleted group"
|
||||
RGEGroupUpdated _ -> "group profile updated"
|
||||
|
||||
sndGroupEventToText :: SndGroupEvent -> Text
|
||||
sndGroupEventToText = \case
|
||||
SGEMemberDeleted _ p -> "removed " <> memberProfileToText p
|
||||
SGEMemberDeleted _ p -> "removed " <> profileToText p
|
||||
SGEUserLeft -> "left"
|
||||
SGEGroupUpdated _ -> "group profile updated"
|
||||
|
||||
memberProfileToText :: Profile -> Text
|
||||
memberProfileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName
|
||||
profileToText :: Profile -> Text
|
||||
profileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName
|
||||
|
||||
-- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
|
||||
data CIContent (d :: MsgDirection) where
|
||||
@@ -536,7 +538,7 @@ deriving instance Show (CIContent d)
|
||||
|
||||
data RcvGroupEvent
|
||||
= RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting
|
||||
| RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember
|
||||
| RGEMemberConnected {contactMainProfile :: Maybe Profile} -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember
|
||||
| RGEMemberLeft -- CRLeftMember
|
||||
| RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember
|
||||
| RGEUserDeleted -- CRDeletedMemberUser
|
||||
@@ -569,13 +571,14 @@ data CIGroupInvitation = CIGroupInvitation
|
||||
groupMemberId :: GroupMemberId,
|
||||
localDisplayName :: GroupName,
|
||||
groupProfile :: GroupProfile,
|
||||
status :: CIGroupInvitationStatus
|
||||
status :: CIGroupInvitationStatus,
|
||||
invitedIncognito :: Maybe Bool
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON CIGroupInvitation where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
toEncoding = J.genericToEncoding J.defaultOptions
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
data CIGroupInvitationStatus
|
||||
= CIGISPending
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20220811_chat_items_indices where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20220811_chat_items_indices :: Query
|
||||
m20220811_chat_items_indices =
|
||||
[sql|
|
||||
CREATE INDEX idx_chat_items_groups ON chat_items(user_id, group_id, item_ts, chat_item_id);
|
||||
CREATE INDEX idx_chat_items_contacts ON chat_items(user_id, contact_id, chat_item_id);
|
||||
|]
|
||||
@@ -0,0 +1,16 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20220812_incognito_profiles where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20220812_incognito_profiles :: Query
|
||||
m20220812_incognito_profiles =
|
||||
[sql|
|
||||
ALTER TABLE connections ADD COLUMN custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- only set for direct connections
|
||||
|
||||
ALTER TABLE group_members ADD COLUMN member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- member profile id if incognito profile was saved for member (used for hosts and invitees in incognito groups)
|
||||
|
||||
ALTER TABLE contact_profiles ADD COLUMN incognito INTEGER; -- 1 for incognito
|
||||
|]
|
||||
@@ -0,0 +1,14 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20220818_chat_notifications where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20220818_chat_notifications :: Query
|
||||
m20220818_chat_notifications =
|
||||
[sql|
|
||||
ALTER TABLE contacts ADD COLUMN enable_ntfs INTEGER;
|
||||
|
||||
ALTER TABLE groups ADD COLUMN enable_ntfs INTEGER;
|
||||
|]
|
||||
@@ -13,7 +13,8 @@ CREATE TABLE contact_profiles(
|
||||
created_at TEXT CHECK(created_at NOT NULL),
|
||||
updated_at TEXT CHECK(updated_at NOT NULL),
|
||||
image TEXT,
|
||||
user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE
|
||||
user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE,
|
||||
incognito INTEGER
|
||||
);
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
display_name,
|
||||
@@ -53,6 +54,7 @@ is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT CHECK(updated_at NOT NULL),
|
||||
xcontact_id BLOB,
|
||||
enable_ntfs INTEGER,
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -118,7 +120,8 @@ CREATE TABLE groups(
|
||||
inv_queue_info BLOB,
|
||||
created_at TEXT CHECK(created_at NOT NULL),
|
||||
updated_at TEXT CHECK(updated_at NOT NULL),
|
||||
chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL, -- received
|
||||
chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL,
|
||||
enable_ntfs INTEGER, -- received
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -145,6 +148,7 @@ CREATE TABLE group_members(
|
||||
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
|
||||
created_at TEXT CHECK(created_at NOT NULL),
|
||||
updated_at TEXT CHECK(updated_at NOT NULL),
|
||||
member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL,
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -236,6 +240,7 @@ CREATE TABLE connections(
|
||||
xcontact_id BLOB,
|
||||
via_user_contact_link INTEGER DEFAULT NULL
|
||||
REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL,
|
||||
custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL,
|
||||
FOREIGN KEY(snd_file_id, connection_id)
|
||||
REFERENCES snd_files(file_id, connection_id)
|
||||
ON DELETE CASCADE
|
||||
@@ -382,3 +387,14 @@ CREATE TABLE calls(
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_chat_items_groups ON chat_items(
|
||||
user_id,
|
||||
group_id,
|
||||
item_ts,
|
||||
chat_item_id
|
||||
);
|
||||
CREATE INDEX idx_chat_items_contacts ON chat_items(
|
||||
user_id,
|
||||
contact_id,
|
||||
chat_item_id
|
||||
);
|
||||
|
||||
@@ -70,6 +70,7 @@ mobileChatOpts =
|
||||
smpServers = [],
|
||||
networkConfig = defaultNetworkConfig,
|
||||
logConnections = False,
|
||||
logServerHosts = True,
|
||||
logAgent = False,
|
||||
chatCmd = "",
|
||||
chatCmdDelay = 3,
|
||||
|
||||
@@ -28,6 +28,7 @@ data ChatOpts = ChatOpts
|
||||
smpServers :: [SMPServer],
|
||||
networkConfig :: NetworkConfig,
|
||||
logConnections :: Bool,
|
||||
logServerHosts :: Bool,
|
||||
logAgent :: Bool,
|
||||
chatCmd :: String,
|
||||
chatCmdDelay :: Int,
|
||||
@@ -52,16 +53,16 @@ chatOpts appDir defaultDbFileName = do
|
||||
( long "server"
|
||||
<> short 's'
|
||||
<> metavar "SERVER"
|
||||
<> help "Comma separated list of SMP server(s) to use"
|
||||
<> help "Semicolon-separated list of SMP server(s) to use (each server can have more than one hostname)"
|
||||
<> value []
|
||||
)
|
||||
socksProxy <-
|
||||
flag' (Just defaultSocksProxy) (short 'x' <> help "use local SOCKS5 proxy at :9050")
|
||||
flag' (Just defaultSocksProxy) (short 'x' <> help "Use local SOCKS5 proxy at :9050")
|
||||
<|> option
|
||||
parseSocksProxy
|
||||
( long "socks-proxy"
|
||||
<> metavar "SOCKS5"
|
||||
<> help "`ipv4:port` or `:port` of SOCKS5 proxy"
|
||||
<> help "Use SOCKS5 proxy at `ipv4:port` or `:port`"
|
||||
<> value Nothing
|
||||
)
|
||||
t <-
|
||||
@@ -78,10 +79,15 @@ chatOpts appDir defaultDbFileName = do
|
||||
<> short 'c'
|
||||
<> help "Log every contact and group connection on start"
|
||||
)
|
||||
logServerHosts <-
|
||||
switch
|
||||
( long "log-hosts"
|
||||
<> short 'l'
|
||||
<> help "Log connections to servers"
|
||||
)
|
||||
logAgent <-
|
||||
switch
|
||||
( long "log-agent"
|
||||
<> short 'l'
|
||||
<> help "Enable logs from SMP agent"
|
||||
)
|
||||
chatCmd <-
|
||||
@@ -123,6 +129,7 @@ chatOpts appDir defaultDbFileName = do
|
||||
smpServers,
|
||||
networkConfig = fullNetworkConfig socksProxy $ useTcpTimeout socksProxy t,
|
||||
logConnections,
|
||||
logServerHosts,
|
||||
logAgent,
|
||||
chatCmd,
|
||||
chatCmdDelay,
|
||||
@@ -151,7 +158,7 @@ serverPortP :: A.Parser (Maybe String)
|
||||
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
|
||||
|
||||
smpServersP :: A.Parser [SMPServer]
|
||||
smpServersP = strP `A.sepBy1` A.char ','
|
||||
smpServersP = strP `A.sepBy1` A.char ';'
|
||||
|
||||
getChatOpts :: FilePath -> FilePath -> IO ChatOpts
|
||||
getChatOpts appDir defaultDbFileName =
|
||||
|
||||
@@ -124,14 +124,14 @@ data ChatMsgEvent
|
||||
| XInfo Profile
|
||||
| XContact Profile (Maybe XContactId)
|
||||
| XGrpInv GroupInvitation
|
||||
| XGrpAcpt MemberId
|
||||
| XGrpAcpt MemberId (Maybe Profile)
|
||||
| XGrpMemNew MemberInfo
|
||||
| XGrpMemIntro MemberInfo
|
||||
| XGrpMemInv MemberId IntroInvitation
|
||||
| XGrpMemFwd MemberInfo IntroInvitation
|
||||
| XGrpMemInfo MemberId Profile
|
||||
| XGrpMemCon MemberId
|
||||
| XGrpMemConAll MemberId
|
||||
| XGrpMemCon MemberId -- TODO not implemented
|
||||
| XGrpMemConAll MemberId -- TODO not implemented
|
||||
| XGrpMemDel MemberId
|
||||
| XGrpLeave
|
||||
| XGrpDel
|
||||
@@ -413,7 +413,7 @@ toCMEventTag = \case
|
||||
XInfo _ -> XInfo_
|
||||
XContact _ _ -> XContact_
|
||||
XGrpInv _ -> XGrpInv_
|
||||
XGrpAcpt _ -> XGrpAcpt_
|
||||
XGrpAcpt _ _ -> XGrpAcpt_
|
||||
XGrpMemNew _ -> XGrpMemNew_
|
||||
XGrpMemIntro _ -> XGrpMemIntro_
|
||||
XGrpMemInv _ _ -> XGrpMemInv_
|
||||
@@ -452,6 +452,7 @@ hasNotification = \case
|
||||
XFile_ -> True
|
||||
XContact_ -> True
|
||||
XGrpInv_ -> True
|
||||
XGrpMemFwd_ -> True
|
||||
XGrpDel_ -> True
|
||||
XCallInv_ -> True
|
||||
_ -> False
|
||||
@@ -478,7 +479,7 @@ appToChatMessage AppMessage {msgId, event, params} = do
|
||||
XInfo_ -> XInfo <$> p "profile"
|
||||
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
|
||||
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberProfile"
|
||||
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
|
||||
XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo"
|
||||
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
|
||||
@@ -520,7 +521,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
|
||||
XInfo profile -> o ["profile" .= profile]
|
||||
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
|
||||
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
|
||||
XGrpAcpt memId -> o ["memberId" .= memId]
|
||||
XGrpAcpt memId profile -> o $ ("memberProfile" .=? profile) ["memberId" .= memId]
|
||||
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
|
||||
XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo]
|
||||
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]
|
||||
|
||||
@@ -26,11 +26,11 @@ terminalChatConfig =
|
||||
InitialAgentServers
|
||||
{ smp =
|
||||
L.fromList
|
||||
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im",
|
||||
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
|
||||
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im"
|
||||
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
|
||||
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
|
||||
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
|
||||
],
|
||||
ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im"],
|
||||
ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"],
|
||||
netCfg = defaultNetworkConfig
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Int (Int64)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Clock (UTCTime)
|
||||
@@ -39,7 +40,7 @@ import Simplex.Messaging.Util ((<$?>))
|
||||
|
||||
class IsContact a where
|
||||
contactId' :: a -> ContactId
|
||||
profile' :: a -> Profile
|
||||
profile' :: a -> LocalProfile
|
||||
localDisplayName' :: a -> ContactName
|
||||
|
||||
instance IsContact User where
|
||||
@@ -56,7 +57,7 @@ data User = User
|
||||
{ userId :: UserId,
|
||||
userContactId :: ContactId,
|
||||
localDisplayName :: ContactName,
|
||||
profile :: Profile,
|
||||
profile :: LocalProfile,
|
||||
activeUser :: Bool
|
||||
}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
@@ -67,12 +68,15 @@ type UserId = ContactId
|
||||
|
||||
type ContactId = Int64
|
||||
|
||||
type ProfileId = Int64
|
||||
|
||||
data Contact = Contact
|
||||
{ contactId :: ContactId,
|
||||
localDisplayName :: ContactName,
|
||||
profile :: Profile,
|
||||
profile :: LocalProfile,
|
||||
activeConn :: Connection,
|
||||
viaGroup :: Maybe Int64,
|
||||
chatSettings :: ChatSettings,
|
||||
createdAt :: UTCTime,
|
||||
updatedAt :: UTCTime
|
||||
}
|
||||
@@ -88,6 +92,9 @@ contactConn = activeConn
|
||||
contactConnId :: Contact -> ConnId
|
||||
contactConnId Contact {activeConn} = aConnId activeConn
|
||||
|
||||
contactConnIncognito :: Contact -> Bool
|
||||
contactConnIncognito Contact {activeConn = Connection {customUserProfileId}} = isJust customUserProfileId
|
||||
|
||||
data ContactRef = ContactRef
|
||||
{ contactId :: ContactId,
|
||||
localDisplayName :: ContactName
|
||||
@@ -184,6 +191,7 @@ data GroupInfo = GroupInfo
|
||||
localDisplayName :: GroupName,
|
||||
groupProfile :: GroupProfile,
|
||||
membership :: GroupMember,
|
||||
chatSettings :: ChatSettings,
|
||||
createdAt :: UTCTime,
|
||||
updatedAt :: UTCTime
|
||||
}
|
||||
@@ -194,10 +202,22 @@ instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOption
|
||||
groupName' :: GroupInfo -> GroupName
|
||||
groupName' GroupInfo {localDisplayName = g} = g
|
||||
|
||||
-- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties)
|
||||
data ChatSettings = ChatSettings
|
||||
{ enableNtfs :: Bool
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
defaultChatSettings :: ChatSettings
|
||||
defaultChatSettings = ChatSettings {enableNtfs = True}
|
||||
|
||||
data Profile = Profile
|
||||
{ displayName :: ContactName,
|
||||
fullName :: Text,
|
||||
image :: Maybe ImageData
|
||||
-- incognito field should not be read as is into this data type to prevent sending it as part of profile to contacts
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
@@ -205,6 +225,29 @@ instance ToJSON Profile where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
data LocalProfile = LocalProfile
|
||||
{ profileId :: ProfileId,
|
||||
displayName :: ContactName,
|
||||
fullName :: Text,
|
||||
image :: Maybe ImageData
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON LocalProfile where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
localProfileId :: LocalProfile -> ProfileId
|
||||
localProfileId = profileId
|
||||
|
||||
toLocalProfile :: ProfileId -> Profile -> LocalProfile
|
||||
toLocalProfile profileId Profile {displayName, fullName, image} =
|
||||
LocalProfile {profileId, displayName, fullName, image}
|
||||
|
||||
fromLocalProfile :: LocalProfile -> Profile
|
||||
fromLocalProfile LocalProfile {displayName, fullName, image} =
|
||||
Profile {displayName, fullName, image}
|
||||
|
||||
data GroupProfile = GroupProfile
|
||||
{ displayName :: GroupName,
|
||||
fullName :: Text,
|
||||
@@ -232,13 +275,16 @@ instance FromField ImageData where fromField = fmap ImageData . fromField
|
||||
|
||||
data GroupInvitation = GroupInvitation
|
||||
{ fromMember :: MemberIdRole,
|
||||
fromMemberProfile :: Maybe Profile,
|
||||
invitedMember :: MemberIdRole,
|
||||
connRequest :: ConnReqInvitation,
|
||||
groupProfile :: GroupProfile
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
instance ToJSON GroupInvitation where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
data MemberIdRole = MemberIdRole
|
||||
{ memberId :: MemberId,
|
||||
@@ -267,7 +313,7 @@ instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptio
|
||||
|
||||
memberInfo :: GroupMember -> MemberInfo
|
||||
memberInfo GroupMember {memberId, memberRole, memberProfile} =
|
||||
MemberInfo memberId memberRole memberProfile
|
||||
MemberInfo memberId memberRole (fromLocalProfile memberProfile)
|
||||
|
||||
data ReceivedGroupInvitation = ReceivedGroupInvitation
|
||||
{ fromMember :: GroupMember,
|
||||
@@ -278,6 +324,8 @@ data ReceivedGroupInvitation = ReceivedGroupInvitation
|
||||
|
||||
type GroupMemberId = Int64
|
||||
|
||||
-- memberProfile's profileId is COALESCE(member_profile_id, contact_profile_id), member_profile_id is non null
|
||||
-- if incognito profile was saved for member (used for hosts and invitees in incognito groups)
|
||||
data GroupMember = GroupMember
|
||||
{ groupMemberId :: GroupMemberId,
|
||||
groupId :: GroupId,
|
||||
@@ -287,8 +335,9 @@ data GroupMember = GroupMember
|
||||
memberStatus :: GroupMemberStatus,
|
||||
invitedBy :: InvitedBy,
|
||||
localDisplayName :: ContactName,
|
||||
memberProfile :: Profile,
|
||||
memberContactId :: Maybe Int64,
|
||||
memberProfile :: LocalProfile,
|
||||
memberContactId :: Maybe ContactId,
|
||||
memberContactProfileId :: ProfileId,
|
||||
activeConn :: Maybe Connection
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
@@ -306,6 +355,9 @@ memberConnId GroupMember {activeConn} = aConnId <$> activeConn
|
||||
groupMemberId' :: GroupMember -> GroupMemberId
|
||||
groupMemberId' GroupMember {groupMemberId} = groupMemberId
|
||||
|
||||
memberIncognito :: GroupMember -> Bool
|
||||
memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId
|
||||
|
||||
data NewGroupMember = NewGroupMember
|
||||
{ memInfo :: MemberInfo,
|
||||
memCategory :: GroupMemberCategory,
|
||||
@@ -695,6 +747,7 @@ data Connection = Connection
|
||||
connLevel :: Int,
|
||||
viaContact :: Maybe Int64, -- group member contact ID, if not direct connection
|
||||
viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address"
|
||||
customUserProfileId :: Maybe Int64,
|
||||
connType :: ConnType,
|
||||
connStatus :: ConnStatus,
|
||||
entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID
|
||||
@@ -705,6 +758,9 @@ data Connection = Connection
|
||||
aConnId :: Connection -> ConnId
|
||||
aConnId Connection {agentConnId = AgentConnId cId} = cId
|
||||
|
||||
connCustomUserProfileId :: Connection -> Maybe Int64
|
||||
connCustomUserProfileId Connection {customUserProfileId} = customUserProfileId
|
||||
|
||||
instance ToJSON Connection where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
@@ -715,6 +771,7 @@ data PendingContactConnection = PendingContactConnection
|
||||
pccConnStatus :: ConnStatus,
|
||||
viaContactUri :: Bool,
|
||||
viaUserContactLink :: Maybe Int64,
|
||||
customUserProfileId :: Maybe Int64,
|
||||
createdAt :: UTCTime,
|
||||
updatedAt :: UTCTime
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import Data.Aeson (ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Char (toUpper)
|
||||
import Data.Function (on)
|
||||
import Data.Int (Int64)
|
||||
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
|
||||
@@ -42,8 +43,9 @@ import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON)
|
||||
import Simplex.Messaging.Protocol (ProtocolServer (..))
|
||||
import Simplex.Messaging.Protocol (AProtocolType, ProtocolServer (..))
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
import Simplex.Messaging.Util (bshow)
|
||||
import System.Console.ANSI.Types
|
||||
|
||||
@@ -52,7 +54,7 @@ serializeChatResponse = unlines . map unStyle . responseToView False
|
||||
|
||||
responseToView :: Bool -> ChatResponse -> [StyledString]
|
||||
responseToView testView = \case
|
||||
CRActiveUser User {profile} -> viewUserProfile profile
|
||||
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
|
||||
CRChatStarted -> ["chat started"]
|
||||
CRChatRunning -> ["chat is running"]
|
||||
CRChatStopped -> ["chat stopped"]
|
||||
@@ -62,8 +64,8 @@ responseToView testView = \case
|
||||
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
|
||||
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
|
||||
CRNetworkConfig cfg -> viewNetworkConfig cfg
|
||||
CRContactInfo ct cStats -> viewContactInfo ct cStats
|
||||
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
|
||||
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
|
||||
CRGroupMemberInfo g m cStats mainProfile -> viewGroupMemberInfo g m cStats mainProfile
|
||||
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item False
|
||||
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems
|
||||
CRChatItemStatusUpdated _ -> []
|
||||
@@ -87,10 +89,10 @@ responseToView testView = \case
|
||||
CRUserContactLink cReqUri autoAccept autoReply -> connReqContact_ "Your chat address:" cReqUri <> autoAcceptStatus_ autoAccept autoReply
|
||||
CRUserContactLinkUpdated _ autoAccept autoReply -> autoAcceptStatus_ autoAccept autoReply
|
||||
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"]
|
||||
CRGroupCreated g -> viewGroupCreated g
|
||||
CRGroupCreated g customUserProfile -> viewGroupCreated g customUserProfile testView
|
||||
CRGroupMembers g -> viewGroupMembers g
|
||||
CRGroupsList gs -> viewGroupsList gs
|
||||
CRSentGroupInvitation g c -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
|
||||
CRSentGroupInvitation g c _ sentCustomProfile -> viewSentGroupInvitation g c sentCustomProfile
|
||||
CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus
|
||||
CRUserProfile p -> viewUserProfile p
|
||||
CRUserProfileNoChange -> ["user profile did not change"]
|
||||
@@ -98,7 +100,7 @@ responseToView testView = \case
|
||||
CRChatCmdError e -> viewChatError e
|
||||
CRInvitation cReq -> viewConnReqInvitation cReq
|
||||
CRSentConfirmation -> ["confirmation sent!"]
|
||||
CRSentInvitation -> ["connection request sent!"]
|
||||
CRSentInvitation customUserProfile -> viewSentInvitation customUserProfile testView
|
||||
CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"]
|
||||
CRChatCleared chatInfo -> viewChatCleared chatInfo
|
||||
CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."]
|
||||
@@ -127,20 +129,22 @@ responseToView testView = \case
|
||||
CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} ->
|
||||
[ttyContact c <> " cancelled receiving " <> sndFile ft]
|
||||
CRContactConnecting _ -> []
|
||||
CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"]
|
||||
CRContactConnected ct userCustomProfile -> viewContactConnected ct userCustomProfile testView
|
||||
CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"]
|
||||
CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> smpServer srv <> " (" <> contactList cs <> ")"]
|
||||
CRContactsSubscribed srv cs -> [plain $ "server connected " <> smpServer srv <> " (" <> contactList cs <> ")"]
|
||||
CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
|
||||
CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
|
||||
CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e]
|
||||
CRContactSubSummary summary ->
|
||||
[sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors"
|
||||
where
|
||||
(errors, subscribed) = partition (isJust . contactError) summary
|
||||
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} ->
|
||||
[groupInvitation' ldn fullName]
|
||||
CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role
|
||||
CRUserJoinedGroup g _ -> [ttyGroup' g <> ": you joined the group"]
|
||||
CRJoinedGroupMember g m -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "]
|
||||
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} ->
|
||||
[groupInvitation' ldn fullName $ memberIncognito membership]
|
||||
CRReceivedGroupInvitation g c role receivedCustomProfile -> viewReceivedGroupInvitation g c role receivedCustomProfile
|
||||
CRUserJoinedGroup g _ usedCustomProfile -> viewUserJoinedGroup g usedCustomProfile testView
|
||||
CRJoinedGroupMember g m mainProfile -> viewJoinedGroupMember g m mainProfile
|
||||
CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h]
|
||||
CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h]
|
||||
CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
|
||||
CRConnectedToGroupMember g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"]
|
||||
CRDeletedMemberUser g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g
|
||||
@@ -152,7 +156,7 @@ responseToView testView = \case
|
||||
CRGroupUpdated g g' m -> viewGroupUpdated g g' m
|
||||
CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e]
|
||||
CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors"
|
||||
CRGroupSubscribed g -> [ttyFullGroup g <> ": connected to server(s)"]
|
||||
CRGroupSubscribed g -> viewGroupSubscribed g
|
||||
CRPendingSubSummary _ -> []
|
||||
CRSndFileSubError SndFileTransfer {fileId, fileName} e ->
|
||||
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
|
||||
@@ -201,11 +205,21 @@ responseToView testView = \case
|
||||
_ -> Nothing
|
||||
viewErrorsSummary :: [a] -> StyledString -> [StyledString]
|
||||
viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)]
|
||||
smpServer :: SMPServer -> String
|
||||
smpServer SMP.ProtocolServer {host, port} = B.unpack . strEncode $ SrvLoc host port
|
||||
contactList :: [ContactRef] -> String
|
||||
contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs
|
||||
|
||||
viewGroupSubscribed :: GroupInfo -> [StyledString]
|
||||
viewGroupSubscribed g@GroupInfo {membership} =
|
||||
[incognito <> ttyFullGroup g <> ": connected to server(s)"]
|
||||
where
|
||||
incognito = if memberIncognito membership then incognitoPrefix else ""
|
||||
|
||||
showSMPServer :: SMPServer -> String
|
||||
showSMPServer = B.unpack . strEncode . host
|
||||
|
||||
viewHostEvent :: AProtocolType -> TransportHost -> String
|
||||
viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h)
|
||||
|
||||
viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> [StyledString]
|
||||
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = case chat of
|
||||
DirectChat c -> case chatDir of
|
||||
@@ -355,6 +369,12 @@ viewConnReqInvitation cReq =
|
||||
"and ask them to connect: " <> highlight' "/c <invitation_link_above>"
|
||||
]
|
||||
|
||||
viewSentGroupInvitation :: GroupInfo -> Contact -> Maybe Profile -> [StyledString]
|
||||
viewSentGroupInvitation g c sentCustomProfile =
|
||||
if isJust sentCustomProfile
|
||||
then ["invitation to join the group " <> ttyGroup' g <> " incognito sent to " <> ttyContact' c]
|
||||
else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
|
||||
|
||||
viewChatCleared :: AChatInfo -> [StyledString]
|
||||
viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
|
||||
DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"]
|
||||
@@ -364,7 +384,8 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
|
||||
viewContactsList :: [Contact] -> [StyledString]
|
||||
viewContactsList =
|
||||
let ldn = T.toLower . (localDisplayName :: Contact -> ContactName)
|
||||
in map ttyFullContact . sortOn ldn
|
||||
incognito ct = if contactConnIncognito ct then incognitoPrefix else ""
|
||||
in map (\ct -> incognito ct <> ttyFullContact ct) . sortOn ldn
|
||||
|
||||
viewUserContactLinkDeleted :: [StyledString]
|
||||
viewUserContactLinkDeleted =
|
||||
@@ -388,6 +409,17 @@ autoAcceptStatus_ autoAccept autoReply =
|
||||
("auto_accept " <> if autoAccept then "on" else "off") :
|
||||
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
|
||||
|
||||
viewSentInvitation :: Maybe Profile -> Bool -> [StyledString]
|
||||
viewSentInvitation incognitoProfile testView =
|
||||
case incognitoProfile of
|
||||
Just profile ->
|
||||
if testView
|
||||
then incognitoProfile' profile : message
|
||||
else message
|
||||
where
|
||||
message = ["connection request sent incognito!"]
|
||||
Nothing -> ["connection request sent!"]
|
||||
|
||||
viewReceivedContactRequest :: ContactName -> Profile -> [StyledString]
|
||||
viewReceivedContactRequest c Profile {fullName} =
|
||||
[ ttyFullName c fullName <> " wants to connect to you!",
|
||||
@@ -395,11 +427,22 @@ viewReceivedContactRequest c Profile {fullName} =
|
||||
"to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)"
|
||||
]
|
||||
|
||||
viewGroupCreated :: GroupInfo -> [StyledString]
|
||||
viewGroupCreated g@GroupInfo {localDisplayName} =
|
||||
[ "group " <> ttyFullGroup g <> " is created",
|
||||
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
|
||||
]
|
||||
viewGroupCreated :: GroupInfo -> Maybe Profile -> Bool -> [StyledString]
|
||||
viewGroupCreated g@GroupInfo {localDisplayName} incognitoProfile testView =
|
||||
case incognitoProfile of
|
||||
Just profile ->
|
||||
if testView
|
||||
then incognitoProfile' profile : message
|
||||
else message
|
||||
where
|
||||
message =
|
||||
[ "group " <> ttyFullGroup g <> " is created incognito, your profile for this group: " <> incognitoProfile' profile,
|
||||
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
|
||||
]
|
||||
Nothing ->
|
||||
[ "group " <> ttyFullGroup g <> " is created",
|
||||
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
|
||||
]
|
||||
|
||||
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
|
||||
viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
|
||||
@@ -407,11 +450,33 @@ viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
|
||||
"to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c)
|
||||
]
|
||||
|
||||
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString]
|
||||
viewReceivedGroupInvitation g c role =
|
||||
[ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role),
|
||||
"use " <> highlight ("/j " <> groupName' g) <> " to accept"
|
||||
]
|
||||
viewUserJoinedGroup :: GroupInfo -> Bool -> Bool -> [StyledString]
|
||||
viewUserJoinedGroup g@GroupInfo {membership = GroupMember {memberProfile}} incognito testView =
|
||||
if incognito
|
||||
then
|
||||
if testView
|
||||
then incognitoProfile' (fromLocalProfile memberProfile) : incognitoMessage
|
||||
else incognitoMessage
|
||||
else [ttyGroup' g <> ": you joined the group"]
|
||||
where
|
||||
incognitoMessage = [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)]
|
||||
|
||||
viewJoinedGroupMember :: GroupInfo -> GroupMember -> Maybe Profile -> [StyledString]
|
||||
viewJoinedGroupMember g m@GroupMember {localDisplayName} = \case
|
||||
Just Profile {displayName = mainProfileName} -> [ttyGroup' g <> ": " <> ttyContact mainProfileName <> " joined the group incognito as " <> styleIncognito localDisplayName]
|
||||
Nothing -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "]
|
||||
|
||||
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> Maybe Profile -> [StyledString]
|
||||
viewReceivedGroupInvitation g c role hostIncognitoProfile =
|
||||
case hostIncognitoProfile of
|
||||
Just profile ->
|
||||
[ ttyFullGroup g <> ": " <> ttyContact' c <> " (known to the group as " <> incognitoProfile' profile <> ") invites you to join the group incognito as " <> plain (strEncode role),
|
||||
"use " <> highlight ("/j " <> groupName' g) <> " to join this group incognito"
|
||||
]
|
||||
Nothing ->
|
||||
[ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role),
|
||||
"use " <> highlight ("/j " <> groupName' g) <> " to accept"
|
||||
]
|
||||
|
||||
groupPreserved :: GroupInfo -> [StyledString]
|
||||
groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"]
|
||||
@@ -426,7 +491,8 @@ viewGroupMembers :: Group -> [StyledString]
|
||||
viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members
|
||||
where
|
||||
removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft
|
||||
groupMember m = ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m
|
||||
groupMember m = incognito m <> ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m
|
||||
incognito m = if memberIncognito m then incognitoPrefix else ""
|
||||
role m = plain . strEncode $ memberRole (m :: GroupMember)
|
||||
category m = case memberCategory m of
|
||||
GCUserMember -> "you, "
|
||||
@@ -442,6 +508,21 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt
|
||||
GSMemCreator -> "created group"
|
||||
_ -> ""
|
||||
|
||||
viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString]
|
||||
viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView =
|
||||
case userIncognitoProfile of
|
||||
Just profile ->
|
||||
if testView
|
||||
then incognitoProfile' profile : message
|
||||
else message
|
||||
where
|
||||
message =
|
||||
[ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile,
|
||||
"use " <> highlight ("/info " <> localDisplayName) <> " to print out this incognito profile again"
|
||||
]
|
||||
Nothing ->
|
||||
[ttyFullContact ct <> ": contact is connected"]
|
||||
|
||||
viewGroupsList :: [GroupInfo] -> [StyledString]
|
||||
viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g <name>"]
|
||||
viewGroupsList gs = map groupSS $ sortOn ldn_ gs
|
||||
@@ -449,9 +530,10 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
|
||||
ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName)
|
||||
groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} =
|
||||
case memberStatus membership of
|
||||
GSMemInvited -> groupInvitation' ldn fullName
|
||||
s -> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s
|
||||
GSMemInvited -> groupInvitation' ldn fullName $ memberIncognito membership
|
||||
s -> incognito <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s
|
||||
where
|
||||
incognito = if memberIncognito membership then incognitoPrefix else ""
|
||||
viewMemberStatus = \case
|
||||
GSMemRemoved -> delete "you are removed"
|
||||
GSMemLeft -> delete "you left"
|
||||
@@ -459,15 +541,20 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
|
||||
_ -> ""
|
||||
delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")"
|
||||
|
||||
groupInvitation' :: GroupName -> Text -> StyledString
|
||||
groupInvitation' displayName fullName =
|
||||
groupInvitation' :: GroupName -> Text -> Bool -> StyledString
|
||||
groupInvitation' displayName fullName membershipIncognito =
|
||||
highlight ("#" <> displayName)
|
||||
<> optFullName displayName fullName
|
||||
<> " - you are invited ("
|
||||
<> invitationText
|
||||
<> highlight ("/j " <> displayName)
|
||||
<> " to join, "
|
||||
<> highlight ("/d #" <> displayName)
|
||||
<> " to delete invitation)"
|
||||
where
|
||||
invitationText =
|
||||
if membershipIncognito
|
||||
then " - you are invited incognito ("
|
||||
else " - you are invited ("
|
||||
|
||||
viewContactsMerged :: Contact -> Contact -> [StyledString]
|
||||
viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} =
|
||||
@@ -506,16 +593,24 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
|
||||
"use `/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]` to change settings"
|
||||
]
|
||||
|
||||
viewContactInfo :: Contact -> ConnectionStats -> [StyledString]
|
||||
viewContactInfo Contact {contactId} stats =
|
||||
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
|
||||
viewContactInfo Contact {contactId} stats incognitoProfile =
|
||||
["contact ID: " <> sShow contactId] <> viewConnectionStats stats
|
||||
<> maybe
|
||||
["you've shared main profile with this contact"]
|
||||
(\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p])
|
||||
incognitoProfile
|
||||
|
||||
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString]
|
||||
viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId} stats =
|
||||
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
|
||||
viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId} stats mainProfile =
|
||||
[ "group ID: " <> sShow groupId,
|
||||
"member ID: " <> sShow groupMemberId
|
||||
]
|
||||
<> maybe ["member not connected"] viewConnectionStats stats
|
||||
<> maybe
|
||||
["unknown whether group member uses his main profile or incognito one for the group"]
|
||||
(\Profile {displayName, fullName} -> ["member is using " <> styleIncognito' "incognito" <> " profile for the group, main profile known: " <> ttyFullName displayName fullName])
|
||||
mainProfile
|
||||
|
||||
viewConnectionStats :: ConnectionStats -> [StyledString]
|
||||
viewConnectionStats ConnectionStats {rcvServers, sndServers} =
|
||||
@@ -526,7 +621,7 @@ viewServers :: [SMPServer] -> StyledString
|
||||
viewServers = plain . intercalate ", " . map (B.unpack . strEncode)
|
||||
|
||||
viewServerHosts :: [SMPServer] -> StyledString
|
||||
viewServerHosts = plain . intercalate ", " . map host
|
||||
viewServerHosts = plain . intercalate ", " . map showSMPServer
|
||||
|
||||
viewUserProfileUpdated :: Profile -> Profile -> [StyledString]
|
||||
viewUserProfileUpdated Profile {displayName = n, fullName, image} Profile {displayName = n', fullName = fullName', image = image'}
|
||||
@@ -551,8 +646,8 @@ viewGroupUpdated
|
||||
|
||||
viewContactUpdated :: Contact -> Contact -> [StyledString]
|
||||
viewContactUpdated
|
||||
Contact {localDisplayName = n, profile = Profile {fullName}}
|
||||
Contact {localDisplayName = n', profile = Profile {fullName = fullName'}}
|
||||
Contact {localDisplayName = n, profile = LocalProfile {fullName}}
|
||||
Contact {localDisplayName = n', profile = LocalProfile {fullName = fullName'}}
|
||||
| n == n' && fullName == fullName' = []
|
||||
| n == n' = ["contact " <> ttyContact n <> fullNameUpdate]
|
||||
| otherwise =
|
||||
@@ -809,6 +904,7 @@ viewChatError = \case
|
||||
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
|
||||
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
|
||||
CEGroupUserRole -> ["you have insufficient permissions for this group command"]
|
||||
CEGroupNotIncognitoCantInvite -> ["you're using main profile for this group - prohibited to invite contact to whom you are connected incognito"]
|
||||
CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"]
|
||||
CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)]
|
||||
CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"]
|
||||
@@ -862,6 +958,8 @@ viewChatError = \case
|
||||
\ secured with different credentials, or due to a bug - please re-create the connection"
|
||||
]
|
||||
AGENT A_DUPLICATE -> []
|
||||
AGENT A_PROHIBITED -> []
|
||||
CONN NOT_FOUND -> []
|
||||
e -> ["smp agent error: " <> sShow e]
|
||||
where
|
||||
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
|
||||
@@ -873,14 +971,14 @@ ttyContact' :: Contact -> StyledString
|
||||
ttyContact' Contact {localDisplayName = c} = ttyContact c
|
||||
|
||||
ttyFullContact :: Contact -> StyledString
|
||||
ttyFullContact Contact {localDisplayName, profile = Profile {fullName}} =
|
||||
ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName}} =
|
||||
ttyFullName localDisplayName fullName
|
||||
|
||||
ttyMember :: GroupMember -> StyledString
|
||||
ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName
|
||||
|
||||
ttyFullMember :: GroupMember -> StyledString
|
||||
ttyFullMember GroupMember {localDisplayName, memberProfile = Profile {fullName}} =
|
||||
ttyFullMember GroupMember {localDisplayName, memberProfile = LocalProfile {fullName}} =
|
||||
ttyFullName localDisplayName fullName
|
||||
|
||||
ttyFullName :: ContactName -> Text -> StyledString
|
||||
@@ -899,7 +997,8 @@ ttyFromContactDeleted :: ContactName -> StyledString
|
||||
ttyFromContactDeleted c = ttyFrom $ c <> "> [deleted] "
|
||||
|
||||
ttyToContact' :: Contact -> StyledString
|
||||
ttyToContact' Contact {localDisplayName = c} = ttyToContact c
|
||||
ttyToContact' Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} =
|
||||
maybe "" (const incognitoPrefix) customUserProfileId <> ttyToContact c
|
||||
|
||||
ttyQuotedContact :: Contact -> StyledString
|
||||
ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">"
|
||||
@@ -909,7 +1008,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c
|
||||
ttyQuotedMember _ = "> " <> ttyFrom "?"
|
||||
|
||||
ttyFromContact' :: Contact -> StyledString
|
||||
ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c
|
||||
ttyFromContact' Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} =
|
||||
maybe "" (const incognitoPrefix) customUserProfileId <> ttyFromContact c
|
||||
|
||||
ttyGroup :: GroupName -> StyledString
|
||||
ttyGroup g = styled (colored Blue) $ "#" <> g
|
||||
@@ -939,10 +1039,12 @@ ttyFrom :: Text -> StyledString
|
||||
ttyFrom = styled $ colored Yellow
|
||||
|
||||
ttyFromGroup' :: GroupInfo -> GroupMember -> StyledString
|
||||
ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m
|
||||
ttyFromGroup' g@GroupInfo {membership} GroupMember {localDisplayName = m} =
|
||||
(if memberIncognito membership then incognitoPrefix else "") <> ttyFromGroup g m
|
||||
|
||||
ttyToGroup :: GroupInfo -> StyledString
|
||||
ttyToGroup GroupInfo {localDisplayName = g} = styled (colored Cyan) $ "#" <> g <> " "
|
||||
ttyToGroup GroupInfo {localDisplayName = g, membership} =
|
||||
(if memberIncognito membership then incognitoPrefix else "") <> styled (colored Cyan) ("#" <> g <> " ")
|
||||
|
||||
ttyFilePath :: FilePath -> StyledString
|
||||
ttyFilePath = plain
|
||||
@@ -950,12 +1052,24 @@ ttyFilePath = plain
|
||||
optFullName :: ContactName -> Text -> StyledString
|
||||
optFullName localDisplayName fullName = plain $ optionalFullName localDisplayName fullName
|
||||
|
||||
incognitoPrefix :: StyledString
|
||||
incognitoPrefix = styleIncognito' "i "
|
||||
|
||||
incognitoProfile' :: Profile -> StyledString
|
||||
incognitoProfile' Profile {displayName} = styleIncognito displayName
|
||||
|
||||
highlight :: StyledFormat a => a -> StyledString
|
||||
highlight = styled $ colored Cyan
|
||||
|
||||
highlight' :: String -> StyledString
|
||||
highlight' = highlight
|
||||
|
||||
styleIncognito :: StyledFormat a => a -> StyledString
|
||||
styleIncognito = styled $ colored Magenta
|
||||
|
||||
styleIncognito' :: String -> StyledString
|
||||
styleIncognito' = styleIncognito
|
||||
|
||||
styleTime :: String -> StyledString
|
||||
styleTime = Styled [SetColor Foreground Vivid Black]
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ extra-deps:
|
||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||
# - ../simplexmq
|
||||
- github: simplex-chat/simplexmq
|
||||
commit: 7d99c4b35cf2dc531219bc83146b714c9bae429c
|
||||
commit: a7b39b710c3aab9b2a38bd6841e52e0342b3a7ef
|
||||
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
|
||||
- github: simplex-chat/aeson
|
||||
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
|
||||
|
||||
@@ -51,6 +51,7 @@ testOpts =
|
||||
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"],
|
||||
networkConfig = defaultNetworkConfig,
|
||||
logConnections = False,
|
||||
logServerHosts = False,
|
||||
logAgent = False,
|
||||
chatCmd = "",
|
||||
chatCmdDelay = 3,
|
||||
@@ -91,7 +92,7 @@ testCfg =
|
||||
testAgentCfgV1 :: AgentConfig
|
||||
testAgentCfgV1 =
|
||||
testAgentCfg
|
||||
{ smpAgentVersion = 1,
|
||||
{ smpClientVRange = mkVersionRange 1 1,
|
||||
smpAgentVRange = mkVersionRange 1 1,
|
||||
smpCfg = (smpCfg testAgentCfg) {smpServerVRange = mkVersionRange 1 1}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import qualified Data.Text as T
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller (ChatController (..))
|
||||
import Simplex.Chat.Options (ChatOpts (..))
|
||||
import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..))
|
||||
import Simplex.Chat.Types (ConnStatus (..), ImageData (..), LocalProfile (..), Profile (..), User (..))
|
||||
import Simplex.Messaging.Util (unlessM)
|
||||
import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
|
||||
import System.FilePath ((</>))
|
||||
@@ -59,7 +59,7 @@ chatTests = do
|
||||
it "group message delete" testGroupMessageDelete
|
||||
it "update group profile" testUpdateGroupProfile
|
||||
describe "async group connections" $ do
|
||||
it "create and join group when clients go offline" testGroupAsync
|
||||
xit "create and join group when clients go offline" testGroupAsync
|
||||
describe "user profiles" $ do
|
||||
it "update user profiles and notify contacts" testUpdateProfile
|
||||
it "update user profile with image" testUpdateProfileImage
|
||||
@@ -88,6 +88,13 @@ chatTests = do
|
||||
it "reject contact and delete contact link" testRejectContactAndDeleteUserContact
|
||||
it "delete connection requests when contact link deleted" testDeleteConnectionRequests
|
||||
it "auto-reply message" testAutoReplyMessage
|
||||
describe "incognito mode" $ do
|
||||
it "connect incognito via invitation link" testConnectIncognitoInvitationLink
|
||||
it "connect incognito via contact address" testConnectIncognitoContactAddress
|
||||
it "accept contact request incognito" testAcceptContactRequestIncognito
|
||||
it "create group incognito" testCreateGroupIncognito
|
||||
it "join group incognito" testJoinGroupIncognito
|
||||
it "can't invite contact to whom user connected incognito to non incognito group" testCantInviteIncognitoConnectionNonIncognitoGroup
|
||||
describe "SMP servers" $
|
||||
it "get and set SMP servers" testGetSetSMPServers
|
||||
describe "async connection handshake" $ do
|
||||
@@ -159,11 +166,13 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
chatsEmpty alice bob
|
||||
alice #> "@bob hello 🙂"
|
||||
bob <# "alice> hello 🙂"
|
||||
alice #> "@bob hello there 🙂"
|
||||
bob <# "alice> hello there 🙂"
|
||||
chatsOneMessage alice bob
|
||||
bob #> "@alice hi"
|
||||
alice <# "bob> hi"
|
||||
bob #> "@alice hello there"
|
||||
alice <# "bob> hello there"
|
||||
bob #> "@alice how are you?"
|
||||
alice <# "bob> how are you?"
|
||||
chatsManyMessages alice bob
|
||||
-- test adding the same contact one more time - local name will be different
|
||||
alice ##> "/c"
|
||||
@@ -177,15 +186,15 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
bob <# "alice_1> hello"
|
||||
bob #> "@alice_1 hi"
|
||||
alice <# "bob_1> hi"
|
||||
alice @@@ [("@bob_1", "hi"), ("@bob", "hi")]
|
||||
bob @@@ [("@alice_1", "hi"), ("@alice", "hi")]
|
||||
alice @@@ [("@bob_1", "hi"), ("@bob", "how are you?")]
|
||||
bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")]
|
||||
-- test deleting contact
|
||||
alice ##> "/d bob_1"
|
||||
alice <## "bob_1: contact is deleted"
|
||||
alice ##> "@bob_1 hey"
|
||||
alice <## "no contact bob_1"
|
||||
alice @@@ [("@bob", "hi")]
|
||||
bob @@@ [("@alice_1", "hi"), ("@alice", "hi")]
|
||||
alice @@@ [("@bob", "how are you?")]
|
||||
bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")]
|
||||
-- test clearing chat
|
||||
alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY")
|
||||
alice #$> ("/_get chat @2 count=100", chat, [])
|
||||
@@ -197,18 +206,20 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
bob @@@ [("@alice", "")]
|
||||
bob #$> ("/_get chat @2 count=100", chat, [])
|
||||
chatsOneMessage alice bob = do
|
||||
alice @@@ [("@bob", "hello 🙂")]
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂")])
|
||||
bob @@@ [("@alice", "hello 🙂")]
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂")])
|
||||
alice @@@ [("@bob", "hello there 🙂")]
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hello there 🙂")])
|
||||
bob @@@ [("@alice", "hello there 🙂")]
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hello there 🙂")])
|
||||
chatsManyMessages alice bob = do
|
||||
alice @@@ [("@bob", "hi")]
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂"), (0, "hi")])
|
||||
bob @@@ [("@alice", "hi")]
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂"), (1, "hi")])
|
||||
alice @@@ [("@bob", "how are you?")]
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")])
|
||||
bob @@@ [("@alice", "how are you?")]
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hello there 🙂"), (1, "hello there"), (1, "how are you?")])
|
||||
-- pagination
|
||||
alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")])
|
||||
alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello 🙂")])
|
||||
alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hello there"), (0, "how are you?")])
|
||||
alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello there 🙂")])
|
||||
-- search
|
||||
alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")])
|
||||
-- read messages
|
||||
alice #$> ("/_read chat @2 from=1 to=100", id, "ok")
|
||||
bob #$> ("/_read chat @2 from=1 to=100", id, "ok")
|
||||
@@ -475,6 +486,7 @@ testGroupShared alice bob cath checkMessages = do
|
||||
-- so we take into account group event items as well as sent group invitations in direct chats
|
||||
alice #$> ("/_get chat #1 after=5 count=100", chat, [(0, "hi there"), (0, "hey team")])
|
||||
alice #$> ("/_get chat #1 before=7 count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")])
|
||||
alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")])
|
||||
bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")]
|
||||
bob #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")])
|
||||
cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")]
|
||||
@@ -2040,6 +2052,394 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $
|
||||
alice <# "@bob hello!"
|
||||
]
|
||||
|
||||
testConnectIncognitoInvitationLink :: IO ()
|
||||
testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
alice #$> ("/incognito on", id, "ok")
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
alice ##> "/c"
|
||||
inv <- getInvitation alice
|
||||
bob ##> ("/c " <> inv)
|
||||
bob <## "confirmation sent!"
|
||||
bobIncognito <- getTermLine bob
|
||||
aliceIncognito <- getTermLine alice
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## (aliceIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## ("use /info " <> aliceIncognito <> " to print out this incognito profile again"),
|
||||
do
|
||||
alice <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## ("use /info " <> bobIncognito <> " to print out this incognito profile again")
|
||||
]
|
||||
-- after turning incognito mode off conversation is incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
alice ?#> ("@" <> bobIncognito <> " psst, I'm incognito")
|
||||
bob ?<# (aliceIncognito <> "> psst, I'm incognito")
|
||||
bob ?#> ("@" <> aliceIncognito <> " <whispering> me too")
|
||||
alice ?<# (bobIncognito <> "> <whispering> me too")
|
||||
-- new contact is connected non incognito
|
||||
connectUsers alice cath
|
||||
alice <##> cath
|
||||
-- bob is not notified on profile change
|
||||
alice ##> "/p alice"
|
||||
concurrentlyN_
|
||||
[ alice <## "user full name removed (your contacts are notified)",
|
||||
cath <## "contact alice removed full name"
|
||||
]
|
||||
alice ?#> ("@" <> bobIncognito <> " do you see that I've changed profile?")
|
||||
bob ?<# (aliceIncognito <> "> do you see that I've changed profile?")
|
||||
bob ?#> ("@" <> aliceIncognito <> " no")
|
||||
alice ?<# (bobIncognito <> "> no")
|
||||
|
||||
testConnectIncognitoContactAddress :: IO ()
|
||||
testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
alice ##> "/ad"
|
||||
cLink <- getContactLink alice True
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
bob ##> ("/c " <> cLink)
|
||||
bobIncognito <- getTermLine bob
|
||||
bob <## "connection request sent incognito!"
|
||||
alice <## (bobIncognito <> " wants to connect to you!")
|
||||
alice <## ("to accept: /ac " <> bobIncognito)
|
||||
alice <## ("to reject: /rc " <> bobIncognito <> " (the sender will NOT be notified)")
|
||||
alice ##> ("/ac " <> bobIncognito)
|
||||
alice <## (bobIncognito <> ": accepting contact request...")
|
||||
_ <- getTermLine bob
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /info alice to print out this incognito profile again",
|
||||
alice <## (bobIncognito <> ": contact is connected")
|
||||
]
|
||||
-- after turning incognito mode off conversation is incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
alice #> ("@" <> bobIncognito <> " who are you?")
|
||||
bob ?<# "alice> who are you?"
|
||||
bob ?#> "@alice I'm Batman"
|
||||
alice <# (bobIncognito <> "> I'm Batman")
|
||||
|
||||
testAcceptContactRequestIncognito :: IO ()
|
||||
testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
alice ##> "/ad"
|
||||
cLink <- getContactLink alice True
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$> ("/incognito on", id, "ok")
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request..."
|
||||
aliceIncognito <- getTermLine alice
|
||||
concurrentlyN_
|
||||
[ bob <## (aliceIncognito <> ": contact is connected"),
|
||||
do
|
||||
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## "use /info bob to print out this incognito profile again"
|
||||
]
|
||||
-- after turning incognito mode off conversation is incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
alice ?#> "@bob my profile is totally inconspicuous"
|
||||
bob <# (aliceIncognito <> "> my profile is totally inconspicuous")
|
||||
bob #> ("@" <> aliceIncognito <> " I know!")
|
||||
alice ?<# "bob> I know!"
|
||||
|
||||
testCreateGroupIncognito :: IO ()
|
||||
testCreateGroupIncognito = testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
-- non incognito connections
|
||||
connectUsers alice cath
|
||||
connectUsers bob cath
|
||||
-- bob connected incognito to alice
|
||||
alice ##> "/c"
|
||||
inv <- getInvitation alice
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
bob ##> ("/c " <> inv)
|
||||
bob <## "confirmation sent!"
|
||||
bobIncognito <- getTermLine bob
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /info alice to print out this incognito profile again",
|
||||
alice <## (bobIncognito <> ": contact is connected")
|
||||
]
|
||||
-- alice creates group incognito
|
||||
alice #$> ("/incognito on", id, "ok")
|
||||
alice ##> "/g secret_club"
|
||||
aliceMemIncognito <- getTermLine alice
|
||||
alice <## ("group #secret_club is created incognito, your profile for this group: " <> aliceMemIncognito)
|
||||
alice <## "use /a secret_club <name> to add members"
|
||||
alice ##> ("/a secret_club " <> bobIncognito)
|
||||
concurrentlyN_
|
||||
[ alice <## ("invitation to join the group #secret_club incognito sent to " <> bobIncognito),
|
||||
do
|
||||
bob <## ("#secret_club: alice (known to the group as " <> aliceMemIncognito <> ") invites you to join the group incognito as admin")
|
||||
bob <## "use /j secret_club to join this group incognito"
|
||||
]
|
||||
-- bob uses different profile when joining group
|
||||
bob ##> "/j secret_club"
|
||||
bobMemIncognito <- getTermLine bob
|
||||
concurrently_
|
||||
(alice <## ("#secret_club: " <> bobIncognito <> " joined the group incognito as " <> bobMemIncognito))
|
||||
(bob <## ("#secret_club: you joined the group incognito as " <> bobMemIncognito))
|
||||
-- cath is invited incognito
|
||||
alice ##> "/a secret_club cath"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #secret_club incognito sent to cath",
|
||||
do
|
||||
cath <## ("#secret_club: alice (known to the group as " <> aliceMemIncognito <> ") invites you to join the group incognito as admin")
|
||||
cath <## "use /j secret_club to join this group incognito"
|
||||
]
|
||||
cath ##> "/j secret_club"
|
||||
cathMemIncognito <- getTermLine cath
|
||||
-- bob and cath don't merge contacts
|
||||
concurrentlyN_
|
||||
[ alice <## ("#secret_club: cath joined the group incognito as " <> cathMemIncognito),
|
||||
do
|
||||
cath <## ("#secret_club: you joined the group incognito as " <> cathMemIncognito)
|
||||
cath <## ("#secret_club: member " <> bobMemIncognito <> " is connected"),
|
||||
do
|
||||
bob <## ("#secret_club: " <> aliceMemIncognito <> " added " <> cathMemIncognito <> " to the group (connecting...)")
|
||||
bob <## ("#secret_club: new member " <> cathMemIncognito <> " is connected")
|
||||
]
|
||||
-- send messages - group is incognito for everybody
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
cath #$> ("/incognito off", id, "ok")
|
||||
alice ?#> "#secret_club hello"
|
||||
concurrently_
|
||||
(bob ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
|
||||
(cath ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
|
||||
bob ?#> "#secret_club hi there"
|
||||
concurrently_
|
||||
(alice ?<# ("#secret_club " <> bobMemIncognito <> "> hi there"))
|
||||
(cath ?<# ("#secret_club " <> bobMemIncognito <> "> hi there"))
|
||||
cath ?#> "#secret_club hey"
|
||||
concurrently_
|
||||
(alice ?<# ("#secret_club " <> cathMemIncognito <> "> hey"))
|
||||
(bob ?<# ("#secret_club " <> cathMemIncognito <> "> hey"))
|
||||
-- bob and cath can send messages via direct incognito connections
|
||||
bob ?#> ("@" <> cathMemIncognito <> " hi, I'm bob")
|
||||
cath ?<# (bobMemIncognito <> "> hi, I'm bob")
|
||||
cath ?#> ("@" <> bobMemIncognito <> " hey, I'm cath")
|
||||
bob ?<# (cathMemIncognito <> "> hey, I'm cath")
|
||||
-- non incognito connections are separate
|
||||
bob <##> cath
|
||||
-- list groups
|
||||
alice ##> "/gs"
|
||||
alice <## "i #secret_club"
|
||||
-- list group members
|
||||
alice ##> "/ms secret_club"
|
||||
alice
|
||||
<### [ "i " <> aliceMemIncognito <> ": owner, you, created group",
|
||||
"i " <> bobMemIncognito <> ": admin, invited, connected",
|
||||
"i " <> cathMemIncognito <> ": admin, invited, connected"
|
||||
]
|
||||
-- remove member
|
||||
bob ##> ("/rm secret_club " <> cathMemIncognito)
|
||||
concurrentlyN_
|
||||
[ bob <## ("#secret_club: you removed " <> cathMemIncognito <> " from the group"),
|
||||
alice <## ("#secret_club: " <> bobMemIncognito <> " removed " <> cathMemIncognito <> " from the group"),
|
||||
do
|
||||
cath <## ("#secret_club: " <> bobMemIncognito <> " removed you from the group")
|
||||
cath <## "use /d #secret_club to delete the group"
|
||||
]
|
||||
bob ?#> "#secret_club hi"
|
||||
concurrently_
|
||||
(alice ?<# ("#secret_club " <> bobMemIncognito <> "> hi"))
|
||||
(cath </)
|
||||
alice ?#> "#secret_club hello"
|
||||
concurrently_
|
||||
(bob ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
|
||||
(cath </)
|
||||
cath ##> "#secret_club hello"
|
||||
cath <## "you are no longer a member of the group"
|
||||
bob ?#> ("@" <> cathMemIncognito <> " I removed you from group")
|
||||
cath ?<# (bobMemIncognito <> "> I removed you from group")
|
||||
cath ?#> ("@" <> bobMemIncognito <> " ok")
|
||||
bob ?<# (cathMemIncognito <> "> ok")
|
||||
|
||||
testJoinGroupIncognito :: IO ()
|
||||
testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $
|
||||
\alice bob cath dan -> do
|
||||
-- non incognito connections
|
||||
connectUsers alice cath
|
||||
connectUsers bob cath
|
||||
connectUsers cath dan
|
||||
-- bob connected incognito to alice
|
||||
alice ##> "/c"
|
||||
inv <- getInvitation alice
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
bob ##> ("/c " <> inv)
|
||||
bob <## "confirmation sent!"
|
||||
bobIncognito <- getTermLine bob
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /info alice to print out this incognito profile again",
|
||||
alice <## (bobIncognito <> ": contact is connected")
|
||||
]
|
||||
-- alice creates group non incognito
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "use /a club <name> to add members"
|
||||
alice ##> ("/a club " <> bobIncognito)
|
||||
concurrentlyN_
|
||||
[ alice <## ("invitation to join the group #club sent to " <> bobIncognito),
|
||||
do
|
||||
bob <## "#club: alice invites you to join the group as admin"
|
||||
bob <## "use /j club to accept"
|
||||
]
|
||||
-- since bob is connected incognito to host, he uses different profile when joining group even though he turned incognito mode off
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
bob ##> "/j club"
|
||||
bobMemIncognito <- getTermLine bob
|
||||
concurrently_
|
||||
(alice <## ("#club: " <> bobIncognito <> " joined the group incognito as " <> bobMemIncognito))
|
||||
(bob <## ("#club: you joined the group incognito as " <> bobMemIncognito))
|
||||
-- cath joins incognito
|
||||
alice ##> "/a club cath"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #club sent to cath",
|
||||
do
|
||||
cath <## "#club: alice invites you to join the group as admin"
|
||||
cath <## "use /j club to accept"
|
||||
]
|
||||
cath #$> ("/incognito on", id, "ok")
|
||||
cath ##> "/j club"
|
||||
cathMemIncognito <- getTermLine cath
|
||||
-- bob and cath don't merge contacts
|
||||
concurrentlyN_
|
||||
[ alice <## ("#club: cath joined the group incognito as " <> cathMemIncognito),
|
||||
do
|
||||
cath <## ("#club: you joined the group incognito as " <> cathMemIncognito)
|
||||
cath <## ("#club: member " <> bobMemIncognito <> " is connected"),
|
||||
do
|
||||
bob <## ("#club: alice added " <> cathMemIncognito <> " to the group (connecting...)")
|
||||
bob <## ("#club: new member " <> cathMemIncognito <> " is connected")
|
||||
]
|
||||
-- cath invites dan incognito
|
||||
cath ##> "/a club dan"
|
||||
concurrentlyN_
|
||||
[ cath <## "invitation to join the group #club incognito sent to dan",
|
||||
do
|
||||
dan <## ("#club: cath (known to the group as " <> cathMemIncognito <> ") invites you to join the group incognito as admin")
|
||||
dan <## "use /j club to join this group incognito"
|
||||
]
|
||||
dan ##> "/j club"
|
||||
danMemIncognito <- getTermLine dan
|
||||
concurrentlyN_
|
||||
[ cath <## ("#club: dan joined the group incognito as " <> danMemIncognito),
|
||||
do
|
||||
dan <## ("#club: you joined the group incognito as " <> danMemIncognito)
|
||||
dan
|
||||
<### [ "#club: member alice (Alice) is connected",
|
||||
"#club: member " <> bobMemIncognito <> " is connected"
|
||||
],
|
||||
do
|
||||
alice <## ("#club: " <> cathMemIncognito <> " added " <> danMemIncognito <> " to the group (connecting...)")
|
||||
alice <## ("#club: new member " <> danMemIncognito <> " is connected"),
|
||||
do
|
||||
bob <## ("#club: " <> cathMemIncognito <> " added " <> danMemIncognito <> " to the group (connecting...)")
|
||||
bob <## ("#club: new member " <> danMemIncognito <> " is connected")
|
||||
]
|
||||
-- send messages - group is incognito for cath and dan
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
cath #$> ("/incognito off", id, "ok")
|
||||
dan #$> ("/incognito off", id, "ok")
|
||||
alice #> "#club hello"
|
||||
concurrentlyN_
|
||||
[ bob ?<# "#club alice> hello",
|
||||
cath ?<# "#club alice> hello",
|
||||
dan ?<# "#club alice> hello"
|
||||
]
|
||||
bob ?#> "#club hi there"
|
||||
concurrentlyN_
|
||||
[ alice <# ("#club " <> bobMemIncognito <> "> hi there"),
|
||||
cath ?<# ("#club " <> bobMemIncognito <> "> hi there"),
|
||||
dan ?<# ("#club " <> bobMemIncognito <> "> hi there")
|
||||
]
|
||||
cath ?#> "#club hey"
|
||||
concurrentlyN_
|
||||
[ alice <# ("#club " <> cathMemIncognito <> "> hey"),
|
||||
bob ?<# ("#club " <> cathMemIncognito <> "> hey"),
|
||||
dan ?<# ("#club " <> cathMemIncognito <> "> hey")
|
||||
]
|
||||
dan ?#> "#club how is it going?"
|
||||
concurrentlyN_
|
||||
[ alice <# ("#club " <> danMemIncognito <> "> how is it going?"),
|
||||
bob ?<# ("#club " <> danMemIncognito <> "> how is it going?"),
|
||||
cath ?<# ("#club " <> danMemIncognito <> "> how is it going?")
|
||||
]
|
||||
-- bob and cath can send messages via direct incognito connections
|
||||
bob ?#> ("@" <> cathMemIncognito <> " hi, I'm bob")
|
||||
cath ?<# (bobMemIncognito <> "> hi, I'm bob")
|
||||
cath ?#> ("@" <> bobMemIncognito <> " hey, I'm cath")
|
||||
bob ?<# (cathMemIncognito <> "> hey, I'm cath")
|
||||
-- non incognito connections are separate
|
||||
bob <##> cath
|
||||
-- bob and dan can send messages via direct incognito connections
|
||||
bob ?#> ("@" <> danMemIncognito <> " hi, I'm bob")
|
||||
dan ?<# (bobMemIncognito <> "> hi, I'm bob")
|
||||
dan ?#> ("@" <> bobMemIncognito <> " hey, I'm dan")
|
||||
bob ?<# (danMemIncognito <> "> hey, I'm dan")
|
||||
-- list group members
|
||||
alice ##> "/ms club"
|
||||
alice
|
||||
<### [ "alice (Alice): owner, you, created group",
|
||||
"i " <> bobMemIncognito <> ": admin, invited, connected",
|
||||
"i " <> cathMemIncognito <> ": admin, invited, connected",
|
||||
danMemIncognito <> ": admin, connected"
|
||||
]
|
||||
bob ##> "/ms club"
|
||||
bob
|
||||
<### [ "alice (Alice): owner, host, connected",
|
||||
"i " <> bobMemIncognito <> ": admin, you, connected",
|
||||
cathMemIncognito <> ": admin, connected",
|
||||
danMemIncognito <> ": admin, connected"
|
||||
]
|
||||
cath ##> "/ms club"
|
||||
cath
|
||||
<### [ "alice (Alice): owner, host, connected",
|
||||
bobMemIncognito <> ": admin, connected",
|
||||
"i " <> cathMemIncognito <> ": admin, you, connected",
|
||||
"i " <> danMemIncognito <> ": admin, invited, connected"
|
||||
]
|
||||
dan ##> "/ms club"
|
||||
dan
|
||||
<### [ "alice (Alice): owner, connected",
|
||||
bobMemIncognito <> ": admin, connected",
|
||||
"i " <> cathMemIncognito <> ": admin, host, connected",
|
||||
"i " <> danMemIncognito <> ": admin, you, connected"
|
||||
]
|
||||
|
||||
testCantInviteIncognitoConnectionNonIncognitoGroup :: IO ()
|
||||
testCantInviteIncognitoConnectionNonIncognitoGroup = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
-- alice connected incognito to bob
|
||||
alice #$> ("/incognito on", id, "ok")
|
||||
alice ##> "/c"
|
||||
inv <- getInvitation alice
|
||||
bob ##> ("/c " <> inv)
|
||||
bob <## "confirmation sent!"
|
||||
aliceIncognito <- getTermLine alice
|
||||
concurrentlyN_
|
||||
[ bob <## (aliceIncognito <> ": contact is connected"),
|
||||
do
|
||||
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## "use /info bob to print out this incognito profile again"
|
||||
]
|
||||
-- alice creates group non incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "use /a club <name> to add members"
|
||||
alice ##> "/a club bob"
|
||||
alice <## "you're using main profile for this group - prohibited to invite contact to whom you are connected incognito"
|
||||
|
||||
testGetSetSMPServers :: IO ()
|
||||
testGetSetSMPServers =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
@@ -2047,7 +2447,7 @@ testGetSetSMPServers =
|
||||
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
|
||||
alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im")
|
||||
alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok")
|
||||
alice #$> ("/smp_servers smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
|
||||
alice #$> ("/smp_servers default", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
|
||||
@@ -2508,7 +2908,7 @@ connectUsers cc1 cc2 = do
|
||||
|
||||
showName :: TestCC -> IO String
|
||||
showName (TestCC ChatController {currentUser} _ _ _ _) = do
|
||||
Just User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser
|
||||
Just User {localDisplayName, profile = LocalProfile {fullName}} <- readTVarIO currentUser
|
||||
pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")"
|
||||
|
||||
createGroup2 :: String -> TestCC -> TestCC -> IO ()
|
||||
@@ -2575,6 +2975,11 @@ cc #> cmd = do
|
||||
cc `send` cmd
|
||||
cc <# cmd
|
||||
|
||||
(?#>) :: TestCC -> String -> IO ()
|
||||
cc ?#> cmd = do
|
||||
cc `send` cmd
|
||||
cc <# ("i " <> cmd)
|
||||
|
||||
(#$>) :: (Eq a, Show a) => TestCC -> (String, String -> a, a) -> Expectation
|
||||
cc #$> (cmd, f, res) = do
|
||||
cc ##> cmd
|
||||
@@ -2627,6 +3032,9 @@ getInAnyOrder f cc ls = do
|
||||
(<#) :: TestCC -> String -> Expectation
|
||||
cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line
|
||||
|
||||
(?<#) :: TestCC -> String -> Expectation
|
||||
cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line
|
||||
|
||||
(</) :: TestCC -> Expectation
|
||||
(</) = (<// 500000)
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
|
||||
|
||||
activeUser :: String
|
||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}"
|
||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}"
|
||||
#else
|
||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}"
|
||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}"
|
||||
#endif
|
||||
|
||||
chatStarted :: String
|
||||
|
||||
@@ -15,7 +15,7 @@ import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.Ratchet
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (parseAll)
|
||||
import Simplex.Messaging.Protocol (smpClientVRange)
|
||||
import Simplex.Messaging.Protocol (supportedSMPClientVRange)
|
||||
import Simplex.Messaging.Version
|
||||
import Test.Hspec
|
||||
|
||||
@@ -28,11 +28,12 @@ srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251")
|
||||
queue :: SMPQueueUri
|
||||
queue =
|
||||
SMPQueueUri
|
||||
{ smpServer = srv,
|
||||
senderId = "\223\142z\251",
|
||||
clientVRange = smpClientVRange,
|
||||
dhPublicKey = "MCowBQYDK2VuAyEAjiswwI3O/NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o="
|
||||
}
|
||||
supportedSMPClientVRange
|
||||
SMPQueueAddress
|
||||
{ smpServer = srv,
|
||||
senderId = "\223\142z\251",
|
||||
dhPublicKey = "MCowBQYDK2VuAyEAjiswwI3O/NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o="
|
||||
}
|
||||
|
||||
connReqData :: ConnReqUriData
|
||||
connReqData =
|
||||
@@ -154,7 +155,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"event\":\"x.msg.deleted\",\"params\":{}}"
|
||||
#==# XMsgDeleted
|
||||
it "x.file" $
|
||||
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Just testConnReq}
|
||||
it "x.file without file invitation" $
|
||||
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
@@ -163,7 +164,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
|
||||
#==# XFileAcpt "photo.jpg"
|
||||
it "x.file.acpt.inv" $
|
||||
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
|
||||
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg"
|
||||
it "x.file.cancel" $
|
||||
"{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}"
|
||||
@@ -186,12 +187,18 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
it "x.contact with content (ignored)" $
|
||||
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
|
||||
==# XContact testProfile Nothing
|
||||
it "x.grp.inv" $
|
||||
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
|
||||
it "x.grp.acpt" $
|
||||
it "x.grp.inv with incognito profile" $
|
||||
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"},\"fromMemberProfile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberProfile = Just testProfile, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
|
||||
it "x.grp.inv without incognito profile" $
|
||||
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberProfile = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
|
||||
it "x.grp.acpt with incognito profile" $
|
||||
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\", \"memberProfile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
|
||||
#==# XGrpAcpt (MemberId "\1\2\3\4") (Just testProfile)
|
||||
it "x.grp.acpt without incognito profile" $
|
||||
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
||||
#==# XGrpAcpt (MemberId "\1\2\3\4")
|
||||
#==# XGrpAcpt (MemberId "\1\2\3\4") Nothing
|
||||
it "x.grp.mem.new" $
|
||||
"{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
|
||||
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
|
||||
@@ -199,10 +206,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
|
||||
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
|
||||
it "x.grp.mem.inv" $
|
||||
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
|
||||
it "x.grp.mem.fwd" $
|
||||
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
|
||||
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
|
||||
it "x.grp.mem.info" $
|
||||
"{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
|
||||
|
||||