diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ab917a6db..e16f719cb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: push: branches: - master - - v4 + - stable tags: - "v*" pull_request: @@ -32,6 +32,7 @@ jobs: uses: softprops/action-gh-release@v1 with: body: ${{ steps.build_changelog.outputs.changelog }} + prerelease: true files: | LICENSE fail_on_unmatched_files: true @@ -49,24 +50,15 @@ jobs: include: - os: ubuntu-20.04 cache_path: ~/.stack - stack_args: "--test" - artifact_rel_path: /bin/simplex-chat asset_name: simplex-chat-ubuntu-20_04-x86-64 - os: ubuntu-18.04 cache_path: ~/.stack - stack_args: "--test" - artifact_rel_path: /bin/simplex-chat asset_name: simplex-chat-ubuntu-18_04-x86-64 - os: macos-latest cache_path: ~/.stack - stack_args: "--test" - artifact_rel_path: /bin/simplex-chat asset_name: simplex-chat-macos-x86-64 - # TODO enable tests for windows once fixed (remove stack_args altogether) - os: windows-latest cache_path: C:/sr - stack_args: "" - artifact_rel_path: /bin/simplex-chat.exe asset_name: simplex-chat-windows-x86-64 steps: - name: Clone project @@ -85,17 +77,51 @@ jobs: path: ${{ matrix.cache_path }} key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }} - - name: Build & test - id: build_test - run: | - stack build ${{ matrix.stack_args }} - echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)" + # / Unix - - name: Upload binaries to release - if: startsWith(github.ref, 'refs/tags/v') + - name: Unix build + id: unix_build + if: matrix.os != 'windows-latest' + shell: bash + run: | + stack build --test + echo "::set-output name=local_install_root::$(stack path --local-install-root)" + + - name: Unix upload binary to release + if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest' uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.build_test.outputs.LOCAL_INSTALL_ROOT }}${{ matrix.artifact_rel_path }} + file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat asset_name: ${{ matrix.asset_name }} tag: ${{ github.ref }} + + # Unix / + + # / Windows + + # * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753 + # * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065 + # * So we're running a separate set of actions for Windows build + + # TODO run tests on Windows + - name: Windows build + id: windows_build + if: matrix.os == 'windows-latest' + shell: cmd + run: | + stack build + stack path --local-install-root > tmp_file + set /p local_install_root= < tmp_file + echo ::set-output name=local_install_root::%local_install_root% + + - name: Windows upload binary to release + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe + asset_name: ${{ matrix.asset_name }} + tag: ${{ github.ref }} + + # Windows / diff --git a/README.md b/README.md index 63f5603c1c..370965983b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The routing of messages relies on the knowledge of client devices how user conta - Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)). - Message integrity validation (via including the digests of the previous messages). - Authentication of each command/message by SMP servers with automatically generated Ed448 keys. -- TLS 1.2 transport encryption. +- TLS 1.3 transport encryption. - Additional encryption of messages from SMP server to recipient to reduce traffic correlation. Public keys involved in key exchange are not used as identity, they are randomly generated for each contact. @@ -125,6 +125,8 @@ move %APPDATA%/local/bin/simplex-chat.exe ### Build from source +> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable). + #### Using Docker On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): @@ -132,6 +134,7 @@ On Linux, you can build the chat executable using [docker build with custom outp ```shell $ git clone git@github.com:simplex-chat/simplex-chat.git $ cd simplex-chat +$ git checkout stable $ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . ``` @@ -150,6 +153,7 @@ and build the project: ```shell $ git clone git@github.com:simplex-chat/simplex-chat.git $ cd simplex-chat +$ git checkout stable $ stack install ``` diff --git a/apps/android/.gitignore b/apps/android/.gitignore new file mode 100644 index 0000000000..b2a8a63042 --- /dev/null +++ b/apps/android/.gitignore @@ -0,0 +1,24 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build/ +release/ +debug/ +/captures +.externalNativeBuild +.cxx +local.properties +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar diff --git a/apps/android/.idea/.gitignore b/apps/android/.idea/.gitignore new file mode 100644 index 0000000000..26d33521af --- /dev/null +++ b/apps/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/apps/android/.idea/.name b/apps/android/.idea/.name new file mode 100644 index 0000000000..ccb58e52e1 --- /dev/null +++ b/apps/android/.idea/.name @@ -0,0 +1 @@ +SimpleX \ No newline at end of file diff --git a/apps/android/.idea/codeStyles/Project.xml b/apps/android/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..4bec4ea8ae --- /dev/null +++ b/apps/android/.idea/codeStyles/Project.xml @@ -0,0 +1,117 @@ + + + + + + \ No newline at end of file diff --git a/apps/android/.idea/codeStyles/codeStyleConfig.xml b/apps/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..a55e7a179b --- /dev/null +++ b/apps/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/apps/android/.idea/compiler.xml b/apps/android/.idea/compiler.xml new file mode 100644 index 0000000000..fb7f4a8a46 --- /dev/null +++ b/apps/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/android/.idea/gradle.xml b/apps/android/.idea/gradle.xml new file mode 100644 index 0000000000..526b4c25c6 --- /dev/null +++ b/apps/android/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/apps/android/.idea/misc.xml b/apps/android/.idea/misc.xml new file mode 100644 index 0000000000..0daaee7f8b --- /dev/null +++ b/apps/android/.idea/misc.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/android/.idea/vcs.xml b/apps/android/.idea/vcs.xml new file mode 100644 index 0000000000..b2bdec2d71 --- /dev/null +++ b/apps/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle new file mode 100644 index 0000000000..dc2b3ed857 --- /dev/null +++ b/apps/android/app/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +android { + compileSdk 32 + + defaultConfig { + applicationId "chat.simplex.app" + minSdk 24 + targetSdk 32 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + ndk { + abiFilters 'arm64-v8a' + } + externalNativeBuild { + cmake { + cppFlags '' + } + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + externalNativeBuild { + cmake { + path file('src/main/cpp/CMakeLists.txt') + version '3.10.2' + } + } + buildFeatures { + viewBinding true + } +} + +dependencies { + + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} \ No newline at end of file diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/apps/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/apps/android/app/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt b/apps/android/app/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..72f99dd818 --- /dev/null +++ b/apps/android/app/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package chat.simplex.app + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("chat.simplex.app", appContext.packageName) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7113a1c6ac --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/android/app/src/main/cpp/CMakeLists.txt b/apps/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..28e2a25b13 --- /dev/null +++ b/apps/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,68 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html + +# Sets the minimum version of CMake required to build the native library. + +cmake_minimum_required(VERSION 3.10.2) + +# Declares and names the project. + +project("app") + +# Creates and names a library, sets it as either STATIC +# or SHARED, and provides the relative paths to its source code. +# You can define multiple libraries, and CMake builds them for you. +# Gradle automatically packages shared libraries with your APK. + +add_library( # Sets the name of the library. + app-lib + + # Sets the library as a shared library. + SHARED + + # Provides a relative path to your source file(s). + simplex-api.c) + +# Searches for a specified prebuilt library and stores the path as a +# variable. Because CMake includes system libraries in the search path by +# default, you only need to specify the name of the public NDK library +# you want to add. CMake verifies that the library exists before +# completing its build. + +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + log) + +find_library( # Sets the name of the path variable. + c-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + c + NAMES libc.so + REQUIRED) + +add_library( simplex SHARED IMPORTED ) +set_target_properties( simplex PROPERTIES IMPORTED_LOCATION + ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsimplex.so) + +add_library( support SHARED IMPORTED ) +set_target_properties( support PROPERTIES IMPORTED_LOCATION + ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so) + + +# Specifies libraries CMake should link to your target library. You +# can link multiple libraries, such as libraries you define in this +# build script, prebuilt third-party libraries, or system libraries. + +target_link_libraries( # Specifies the target library. + app-lib + + simplex support + + # Links the target library to the log library + # included in the NDK. + ${log-lib}) \ No newline at end of file diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c new file mode 100644 index 0000000000..7aa1924386 --- /dev/null +++ b/apps/android/app/src/main/cpp/simplex-api.c @@ -0,0 +1,72 @@ +#include + +// from the RTS +void hs_init(int * argc, char **argv[]); + +// from android-support +void setLineBuffering(void); +int pipe_std_to_socket(const char * name); + +JNIEXPORT jint JNICALL +Java_chat_simplex_app_MainActivityKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) { + const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE); + int ret = pipe_std_to_socket(name); + (*env)->ReleaseStringUTFChars(env, socket_name, name); + return ret; +} + +JNIEXPORT void JNICALL +Java_chat_simplex_app_MainActivityKt_initHS(__unused JNIEnv *env, __unused jclass clazz) { + hs_init(NULL, NULL); + setLineBuffering(); +} + +// from simplex-chat +typedef void* chat_store; +typedef void* controller; + +extern chat_store chat_init_store(const char * path); +extern char *chat_get_user(chat_store store); +extern char *chat_create_user(chat_store store, const char *data); +extern controller chat_start(chat_store store); +extern char *chat_send_cmd(controller ctl, const char *cmd); +extern char *chat_recv_msg(controller ctl); + +JNIEXPORT jlong JNICALL +Java_chat_simplex_app_MainActivityKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) { + const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE); + jlong res = (jlong)chat_init_store(_data); + (*env)->ReleaseStringUTFChars(env, datadir, _data); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_MainActivityKt_chatGetUser(JNIEnv *env, __unused jclass clazz, jlong controller) { + return (*env)->NewStringUTF(env, chat_get_user((void*)controller)); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_MainActivityKt_chatCreateUser(JNIEnv *env, __unused jclass clazz, jlong controller, jstring data) { + const char *_data = (*env)->GetStringUTFChars(env, data, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_create_user((void*)controller, _data)); + (*env)->ReleaseStringUTFChars(env, data, _data); + return res; +} + +JNIEXPORT jlong JNICALL +Java_chat_simplex_app_MainActivityKt_chatStart(JNIEnv *env, jclass clazz, jlong controller) { + return (jlong)chat_start((void*)controller); +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_MainActivityKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) { + const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg)); + (*env)->ReleaseStringUTFChars(env, msg, _msg); + return res; +} + +JNIEXPORT jstring JNICALL +Java_chat_simplex_app_MainActivityKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { + return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt new file mode 100644 index 0000000000..20897023c3 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -0,0 +1,123 @@ +package chat.simplex.app + +import android.net.LocalServerSocket +import android.os.Bundle +import android.util.Log +import android.view.inputmethod.EditorInfo +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatEditText +import java.io.BufferedReader +import java.io.InputStreamReader +import java.lang.ref.WeakReference +import java.util.* +import java.util.concurrent.Semaphore +import kotlin.concurrent.thread + +// ghc's rts +external fun initHS() +// android-support +external fun pipeStdOutToSocket(socketName: String) : Int + +// simplex-chat +typealias Store = Long +typealias Controller = Long +external fun chatInit(filesDir: String): Store +external fun chatGetUser(controller: Store) : String +external fun chatCreateUser(controller: Store, data: String) : String +external fun chatStart(controller: Store) : Controller +external fun chatSendCmd(controller: Controller, msg: String) : String +external fun chatRecvMsg(controller: Controller) : String + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + weakActivity = WeakReference(this) + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val store : Store = chatInit(this.applicationContext.filesDir.toString()) + // create user if needed + if(chatGetUser(store) == "{}") { + chatCreateUser(store, """ + {"displayName": "test", "fullName": "android test"} + """.trimIndent()) + } + Log.d("SIMPLEX (user)", chatGetUser(store)) + + val controller = chatStart(store) + + val cmdinput = this.findViewById(R.id.cmdInput) + + cmdinput.setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_SEND -> { + Log.d("SIMPLEX SEND", chatSendCmd(controller, cmdinput.text.toString())) + cmdinput.text?.clear() + true + } + else -> false + } + } + + thread(name="receiver") { + val chatlog = FifoQueue(500) + while(true) { + val msg = chatRecvMsg(controller) + Log.d("SIMPLEX RECV", msg) + chatlog.add(msg) + val currentText = chatlog.joinToString("\n") + weakActivity.get()?.runOnUiThread { + val log = weakActivity.get()?.findViewById(R.id.chatlog) + val scroll = weakActivity.get()?.findViewById(R.id.scroller) + log?.text = currentText + scroll?.scrollTo(0, scroll.getChildAt(0).height) + } + } + } + } + + companion object { + lateinit var weakActivity : WeakReference + init { + val socketName = "local.socket.address.listen.native.cmd2" + + val s = Semaphore(0) + thread(name="stdout/stderr pipe") { + Log.d("SIMPLEX", "starting server") + val server = LocalServerSocket(socketName) + Log.d("SIMPLEX", "started server") + s.release() + val receiver = server.accept() + Log.d("SIMPLEX", "started receiver") + val logbuffer = FifoQueue(500) + if (receiver != null) { + val inStream = receiver.inputStream + val inStreamReader = InputStreamReader(inStream) + val input = BufferedReader(inStreamReader) + + while(true) { + val line = input.readLine() ?: break + Log.d("SIMPLEX (stdout/stderr)", line) + logbuffer.add(line) + } + } + } + + System.loadLibrary("app-lib") + + s.acquire() + pipeStdOutToSocket(socketName) + + initHS() + } + } +} + +class FifoQueue(private var capacity: Int) : LinkedList() { + override fun add(element: E): Boolean { + if(size > capacity) removeFirst() + return super.add(element) + } +} diff --git a/apps/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/apps/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..5c3bfcd6c3 --- /dev/null +++ b/apps/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/android/app/src/main/res/drawable/ic_launcher_background.xml b/apps/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..140f829468 --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/res/layout/activity_main.xml b/apps/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..87429b38a0 --- /dev/null +++ b/apps/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..03eed2533d --- /dev/null +++ b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..03eed2533d --- /dev/null +++ b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/apps/android/app/src/main/res/values-night/themes.xml b/apps/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..639e5393c7 --- /dev/null +++ b/apps/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..09837df62f --- /dev/null +++ b/apps/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..c8517623e9 --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SimpleX + \ No newline at end of file diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..781d40bcf4 --- /dev/null +++ b/apps/android/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/apps/android/app/src/test/java/chat/simplex/app/ExampleUnitTest.kt b/apps/android/app/src/test/java/chat/simplex/app/ExampleUnitTest.kt new file mode 100644 index 0000000000..eb08839d27 --- /dev/null +++ b/apps/android/app/src/test/java/chat/simplex/app/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package chat.simplex.app + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/apps/android/build.gradle b/apps/android/build.gradle new file mode 100644 index 0000000000..9988a75fc6 --- /dev/null +++ b/apps/android/build.gradle @@ -0,0 +1,18 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:7.0.4" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties new file mode 100644 index 0000000000..98bed167dc --- /dev/null +++ b/apps/android/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..05bd558a5e --- /dev/null +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jan 21 23:13:54 GMT 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/apps/android/gradlew b/apps/android/gradlew new file mode 100755 index 0000000000..4f906e0c81 --- /dev/null +++ b/apps/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/apps/android/gradlew.bat b/apps/android/gradlew.bat new file mode 100644 index 0000000000..ac1b06f938 --- /dev/null +++ b/apps/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/apps/android/settings.gradle b/apps/android/settings.gradle new file mode 100644 index 0000000000..71e4f1f472 --- /dev/null +++ b/apps/android/settings.gradle @@ -0,0 +1,10 @@ +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + jcenter() // Warning: this repository is going to shut down soon + } +} +rootProject.name = "SimpleX" +include ':app' diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000000..5137305d86 --- /dev/null +++ b/apps/ios/.gitignore @@ -0,0 +1,67 @@ +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +Libraries/ diff --git a/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..481421680d --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d6bfcc76f4 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,172 @@ +{ + "images" : [ + { + "filename" : "Icon-App-20x20@2x-1.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@1x.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-60x60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-60x60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-20x20@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@1x-1.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x-1.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@2x-1.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-76x76@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-76x76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-83.5x83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "Icon-App-1024x1024@1x.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..6dbc1d95ad Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..280e05c98e Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png new file mode 100644 index 0000000000..12a1031ff0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..12a1031ff0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..910e01901d Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x-1.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x-1.png new file mode 100644 index 0000000000..3fa10ee124 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x-1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..3fa10ee124 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png new file mode 100644 index 0000000000..8ab45daa1b Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..8ab45daa1b Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..b0191a721f Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..12a1031ff0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png new file mode 100644 index 0000000000..c4eeb166ac Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..c4eeb166ac Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..1c3a1a3e2b Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..1c3a1a3e2b Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..853f24990f Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..0f589f5e97 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..01d4f69d6a Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..71fb5a0b2f Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/Contents.json b/apps/ios/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json new file mode 100644 index 0000000000..e30e4bc7ce --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "github32px.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "github64px.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "github64px-1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png new file mode 100644 index 0000000000..8b25551a97 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png new file mode 100644 index 0000000000..182a1a3f73 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png new file mode 100644 index 0000000000..182a1a3f73 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png differ diff --git a/apps/ios/Shared/Assets.xcassets/logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000000..8fcba17f9c --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "logo-2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo-1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/logo.imageset/logo-1.png b/apps/ios/Shared/Assets.xcassets/logo.imageset/logo-1.png new file mode 100644 index 0000000000..7f25ea17d0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/logo.imageset/logo-1.png differ diff --git a/apps/ios/Shared/Assets.xcassets/logo.imageset/logo-2.png b/apps/ios/Shared/Assets.xcassets/logo.imageset/logo-2.png new file mode 100644 index 0000000000..7f25ea17d0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/logo.imageset/logo-2.png differ diff --git a/apps/ios/Shared/Assets.xcassets/logo.imageset/logo.png b/apps/ios/Shared/Assets.xcassets/logo.imageset/logo.png new file mode 100644 index 0000000000..7f25ea17d0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/logo.imageset/logo.png differ diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift new file mode 100644 index 0000000000..0392e7274b --- /dev/null +++ b/apps/ios/Shared/ContentView.swift @@ -0,0 +1,52 @@ +// +// ContentView.swift +// Shared +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var chatModel: ChatModel + @State private var showNotificationAlert = false + + var body: some View { + if let user = chatModel.currentUser { + ChatListView(user: user) + .onAppear { + do { + try apiStartChat() + chatModel.chats = try apiGetChats() + } catch { + fatalError("Failed to start or load chats: \(error)") + } + ChatReceiver.shared.start() + NtfManager.shared.requestAuthorization(onDeny: { + showNotificationAlert = true + }) + } + .alert(isPresented : $showNotificationAlert){ + Alert( + title: Text("Notification are disabled!"), + message: Text("Please open settings to enable"), + primaryButton: .default(Text("Open Settings")) { + DispatchQueue.main.async { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) + } + }, + secondaryButton: .cancel() + ) + } + } else { + WelcomeView() + } + } +} + + +//struct ContentView_Previews: PreviewProvider { +// static var previews: some View { +// ContentView(text: "Hello!") +// } +//} diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift new file mode 100644 index 0000000000..8b66fb6e67 --- /dev/null +++ b/apps/ios/Shared/Model/BGManager.swift @@ -0,0 +1,80 @@ +// +// BGManager.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 08/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import BackgroundTasks + +private let receiveTaskId = "chat.simplex.app.receive" + +// TCP timeout + 2 sec +private let waitForMessages: TimeInterval = 6 + +private let bgRefreshInterval: TimeInterval = 450 + +class BGManager { + static let shared = BGManager() + var chatReceiver: ChatReceiver? + var bgTimer: Timer? + var completed = false + + func register() { + logger.debug("BGManager.register") + BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in + self.handleRefresh(task as! BGAppRefreshTask) + } + } + + func schedule() { + logger.debug("BGManager.schedule") + let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) + request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + logger.error("BGManager.schedule error: \(error.localizedDescription)") + } + } + + private func handleRefresh(_ task: BGAppRefreshTask) { + logger.debug("BGManager.handleRefresh") + schedule() + self.completed = false + + let completeTask: (String) -> Void = { reason in + logger.debug("BGManager.handleRefresh completeTask: \(reason)") + if !self.completed { + self.completed = true + self.chatReceiver?.stop() + self.chatReceiver = nil + self.bgTimer?.invalidate() + self.bgTimer = nil + task.setTaskCompleted(success: true) + } + } + + task.expirationHandler = { completeTask("expirationHandler") } + DispatchQueue.main.async { + initializeChat() + if ChatModel.shared.currentUser == nil { + completeTask("no current user") + return + } + logger.debug("BGManager.handleRefresh: starting chat") + let cr = ChatReceiver() + self.chatReceiver = cr + cr.start() + RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in + logger.debug("BGManager.handleRefresh: timer") + self.bgTimer = timer + if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages { + completeTask("timer (no messages after \(waitForMessages) seconds)") + } + }, forMode: .default) + } + } +} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift new file mode 100644 index 0000000000..91265fb85f --- /dev/null +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -0,0 +1,541 @@ +// +// ChatModel.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 22/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import Combine +import SwiftUI + +final class ChatModel: ObservableObject { + @Published var currentUser: User? + // list of chat "previews" + @Published var chats: [Chat] = [] + // current chat + @Published var chatId: String? + @Published var chatItems: [ChatItem] = [] + // items in the terminal view + @Published var terminalItems: [TerminalItem] = [] + @Published var userAddress: String? + @Published var appOpenUrl: URL? + @Published var connectViaUrl = false + static let shared = ChatModel() + + func hasChat(_ id: String) -> Bool { + chats.first(where: { $0.id == id }) != nil + } + + func getChat(_ id: String) -> Chat? { + chats.first(where: { $0.id == id }) + } + + private func getChatIndex(_ id: String) -> Int? { + chats.firstIndex(where: { $0.id == id }) + } + + func addChat(_ chat: Chat) { + withAnimation { + chats.insert(chat, at: 0) + } + } + + func updateChatInfo(_ cInfo: ChatInfo) { + if let ix = getChatIndex(cInfo.id) { + chats[ix].chatInfo = cInfo + } + } + + func updateContact(_ contact: Contact) { + let cInfo = ChatInfo.direct(contact: contact) + if hasChat(contact.id) { + updateChatInfo(cInfo) + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [])) + } + } + + func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) { + if let ix = getChatIndex(contact.id) { + chats[ix].serverInfo.networkStatus = status + } + } + + func replaceChat(_ id: String, _ chat: Chat) { + if let ix = chats.firstIndex(where: { $0.id == id }) { + chats[ix] = chat + } else { + // invalid state, correcting + chats.insert(chat, at: 0) + } + } + + func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { + chats[ix].chatItems = [cItem] + if ix > 0 { + if chatId == nil { + withAnimation { popChat(ix) } + } else { + DispatchQueue.main.async { self.popChat(ix) } + } + } + } + if chatId == cInfo.id { + withAnimation { chatItems.append(cItem) } + } + } + + private func popChat(_ ix: Int) { + let chat = chats.remove(at: ix) + chats.insert(chat, at: 0) + } + + func removeChat(_ id: String) { + withAnimation { + chats.removeAll(where: { $0.id == id }) + } + } +} + +struct User: Decodable, NamedChat { + var userId: Int64 + var userContactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeUser: Bool + +// internal init(userId: Int64, userContactId: Int64, localDisplayName: ContactName, profile: Profile, activeUser: Bool) { +// self.userId = userId +// self.userContactId = userContactId +// self.localDisplayName = localDisplayName +// self.profile = profile +// self.activeUser = activeUser +// } + + var displayName: String { get { profile.displayName } } + + var fullName: String { get { profile.fullName } } + + static let sampleData = User( + userId: 1, + userContactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeUser: true + ) +} + +typealias ContactName = String + +typealias GroupName = String + +struct Profile: Codable, NamedChat { + var displayName: String + var fullName: String + + static let sampleData = Profile( + displayName: "alice", + fullName: "Alice" + ) +} + +enum ChatType: String { + case direct = "@" + case group = "#" + case contactRequest = "<@" +} + +protocol NamedChat { + var displayName: String { get } + var fullName: String { get } +} + +extension NamedChat { + var chatViewName: String { + get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } + } +} + +typealias ChatId = String + +enum ChatInfo: Identifiable, Decodable, NamedChat { + case direct(contact: Contact) + case group(groupInfo: GroupInfo) + case contactRequest(contactRequest: UserContactRequest) + + var localDisplayName: String { + get { + switch self { + case let .direct(contact): return contact.localDisplayName + case let .group(groupInfo): return groupInfo.localDisplayName + case let .contactRequest(contactRequest): return contactRequest.localDisplayName + } + } + } + + var displayName: String { + get { + switch self { + case let .direct(contact): return contact.displayName + case let .group(groupInfo): return groupInfo.displayName + case let .contactRequest(contactRequest): return contactRequest.displayName + } + } + } + + var fullName: String { + get { + switch self { + case let .direct(contact): return contact.fullName + case let .group(groupInfo): return groupInfo.fullName + case let .contactRequest(contactRequest): return contactRequest.fullName + } + } + } + + var id: ChatId { + get { + switch self { + case let .direct(contact): return contact.id + case let .group(groupInfo): return groupInfo.id + case let .contactRequest(contactRequest): return contactRequest.id + } + } + } + + var chatType: ChatType { + get { + switch self { + case .direct: return .direct + case .group: return .group + case .contactRequest: return .contactRequest + } + } + } + + var apiId: Int64 { + get { + switch self { + case let .direct(contact): return contact.apiId + case let .group(groupInfo): return groupInfo.apiId + case let .contactRequest(contactRequest): return contactRequest.apiId + } + } + } + + var createdAt: Date { + switch self { + case let .direct(contact): return contact.createdAt + case let .group(groupInfo): return groupInfo.createdAt + case let .contactRequest(contactRequest): return contactRequest.createdAt + } + } + + struct SampleData { + var direct: ChatInfo + var group: ChatInfo + var contactRequest: ChatInfo + } + + static var sampleData: ChatInfo.SampleData = SampleData( + direct: ChatInfo.direct(contact: Contact.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) + ) +} + +final class Chat: ObservableObject, Identifiable { + @Published var chatInfo: ChatInfo + @Published var chatItems: [ChatItem] + @Published var serverInfo = ServerInfo(networkStatus: .unknown) + + struct ServerInfo: Decodable { + var networkStatus: NetworkStatus + } + + enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(String) + + var statusString: String { + get { + switch self { + case .connected: return "Server connected" + case let .error(err): return "Connecting server… (error: \(err))" + default: return "Connecting server…" + } + } + } + + var statusExplanation: String { + get { + switch self { + case .connected: return "You are connected to the server you use to receve messages from this contact." + case let .error(err): return "Trying to connect to the server you use to receve messages from this contact (error: \(err))." + default: return "Trying to connect to the server you use to receve messages from this contact." + } + } + } + + var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } + } + + init(_ cData: ChatData) { + self.chatInfo = cData.chatInfo + self.chatItems = cData.chatItems + } + + init(chatInfo: ChatInfo, chatItems: [ChatItem] = []) { + self.chatInfo = chatInfo + self.chatItems = chatItems + } + + var id: ChatId { get { chatInfo.id } } +} + +struct ChatData: Decodable, Identifiable { + var chatInfo: ChatInfo + var chatItems: [ChatItem] + + var id: ChatId { get { chatInfo.id } } +} + +struct Contact: Identifiable, Decodable, NamedChat { + var contactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeConn: Connection + var viaGroup: Int64? + var createdAt: Date + + var id: ChatId { get { "@\(contactId)" } } + var apiId: Int64 { get { contactId } } + var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + + static let sampleData = Contact( + contactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeConn: Connection.sampleData, + createdAt: .now + ) +} + +struct Connection: Decodable { + var connStatus: String + + static let sampleData = Connection(connStatus: "ready") +} + +struct UserContactRequest: Decodable, NamedChat { + var contactRequestId: Int64 + var localDisplayName: ContactName + var profile: Profile + var createdAt: Date + + var id: ChatId { get { "<@\(contactRequestId)" } } + var apiId: Int64 { get { contactRequestId } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + + static let sampleData = UserContactRequest( + contactRequestId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + createdAt: .now + ) +} + +struct GroupInfo: Identifiable, Decodable, NamedChat { + var groupId: Int64 + var localDisplayName: GroupName + var groupProfile: GroupProfile + var createdAt: Date + + var id: ChatId { get { "#\(groupId)" } } + var apiId: Int64 { get { groupId } } + var displayName: String { get { groupProfile.displayName } } + var fullName: String { get { groupProfile.fullName } } + + static let sampleData = GroupInfo( + groupId: 1, + localDisplayName: "team", + groupProfile: GroupProfile.sampleData, + createdAt: .now + ) +} + +struct GroupProfile: Codable, NamedChat { + var displayName: String + var fullName: String + + static let sampleData = GroupProfile( + displayName: "team", + fullName: "My Team" + ) +} + +struct GroupMember: Decodable { + var groupMemberId: Int64 + var memberId: String +// var memberRole: GroupMemberRole +// var memberCategory: GroupMemberCategory +// var memberStatus: GroupMemberStatus +// var invitedBy: InvitedBy + var localDisplayName: ContactName + var memberProfile: Profile + var memberContactId: Int64? +// var activeConn: Connection? + + static let sampleData = GroupMember( + groupMemberId: 1, + memberId: "abcd", + localDisplayName: "alice", + memberProfile: Profile.sampleData, + memberContactId: 1 + ) +} + +struct AChatItem: Decodable { + var chatInfo: ChatInfo + var chatItem: ChatItem +} + +struct ChatItem: Identifiable, Decodable { + var chatDir: CIDirection + var meta: CIMeta + var content: CIContent + + var id: Int64 { get { meta.itemId } } + + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text), + content: .sndMsgContent(msgContent: .text(text)) + ) + } +} + +enum CIDirection: Decodable { + case directSnd + case directRcv + case groupSnd + case groupRcv(groupMember: GroupMember) + + var sent: Bool { + get { + switch self { + case .directSnd: return true + case .directRcv: return false + case .groupSnd: return true + case .groupRcv: return false + } + } + } +} + +struct CIMeta: Decodable { + var itemId: Int64 + var itemTs: Date + var itemText: String + var createdAt: Date + + static func getSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta { + CIMeta( + itemId: id, + itemTs: ts, + itemText: text, + createdAt: ts + ) + } +} + +enum CIStatus: Decodable { + case sndNew + case sndSent + case sndErrorAuth + case sndError(agentErrorType: AgentErrorType) + case rcvNew + case rcvRead +} + +enum CIContent: Decodable { + case sndMsgContent(msgContent: MsgContent) + case rcvMsgContent(msgContent: MsgContent) + // files etc. + + var text: String { + get { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + } + } + } +} + +enum MsgContent { + case text(String) + case unknown(type: String, text: String) + case invalid(error: String) + + var text: String { + get { + switch self { + case let .text(text): return text + case let .unknown(_, text): return text + case .invalid: return "invalid" + } + } + } + + var cmdString: String { + get { + switch self { + case let .text(text): return "text \(text)" + default: return "" + } + } + } + + enum CodingKeys: String, CodingKey { + case type + case text + } +} + +extension MsgContent: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + do { + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "text": + let text = try container.decode(String.self, forKey: CodingKeys.text) + self = .text(text) + default: + let text = try? container.decode(String.self, forKey: CodingKeys.text) + self = .unknown(type: type, text: text ?? "unknown message format") + } + } catch { + self = .invalid(error: String(describing: error)) + } + } +} diff --git a/apps/ios/Shared/Model/JSON.swift b/apps/ios/Shared/Model/JSON.swift new file mode 100644 index 0000000000..2123c54811 --- /dev/null +++ b/apps/ios/Shared/Model/JSON.swift @@ -0,0 +1,38 @@ +// +// JSON.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +func getJSONDecoder() -> JSONDecoder { + let jd = JSONDecoder() + let fracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ") + let noFracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ssZZZZZ") + jd.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = fracSeconds.date(from: string) ?? noFracSeconds.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)") + } + return jd +} + +func getJSONEncoder() -> JSONEncoder { + let je = JSONEncoder() + je.dateEncodingStrategy = .iso8601 + return je +} + +private func getDateFormatter(_ format: String) -> DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = format + df.timeZone = TimeZone(secondsFromGMT: 0) + return df +} diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift new file mode 100644 index 0000000000..904357eec6 --- /dev/null +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -0,0 +1,199 @@ +// +// NtfManager.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 08/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UserNotifications +import UIKit + +let ntfActionAccept = "NTF_ACT_ACCEPT" + +let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" +let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" +let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" + +let appNotificationId = "chat.simplex.app.notification" + +private let ntfTimeInterval: TimeInterval = 1 + +class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + static let shared = NtfManager() + + private var granted = false + private var prevNtfTime: Dictionary = [:] + + + // Handle notification when app is in background + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler handler: () -> Void) { + logger.debug("NtfManager.userNotificationCenter: didReceive") + let content = response.notification.request.content + let chatModel = ChatModel.shared + if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept, + let chatId = content.userInfo["chatId"] as? String, + case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { + acceptContactRequest(contactRequest) + } else { + chatModel.chatId = content.targetContentIdentifier + } + handler() + } + + // Handle notification when the app is in foreground + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler handler: (UNNotificationPresentationOptions) -> Void) { + logger.debug("NtfManager.userNotificationCenter: willPresent") + handler(presentationOptions(notification.request.content)) + } + + private func presentationOptions(_ content: UNNotificationContent) -> UNNotificationPresentationOptions { + let model = ChatModel.shared + if UIApplication.shared.applicationState == .active { + switch content.categoryIdentifier { + case ntfCategoryContactRequest: + return [.sound, .banner, .list] + case ntfCategoryContactConnected: + return model.chatId == nil ? [.sound, .list] : [.sound, .banner, .list] + case ntfCategoryMessageReceived: + if model.chatId == nil { + // in the chat list + return recentInTheSameChat(content) ? [] : [.sound, .list] + } else if model.chatId == content.targetContentIdentifier { + // in the current chat + return recentInTheSameChat(content) ? [] : [.sound, .list] + } else { + // in another chat + return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list] + } + default: return [.sound, .banner, .list] + } + } else { + return [.sound, .banner, .list] + } + } + + private func recentInTheSameChat(_ content: UNNotificationContent) -> Bool { + let now = Date.now + if let chatId = content.targetContentIdentifier { + var res: Bool = false + if let t = prevNtfTime[chatId] { res = t.distance(to: now) < 30 } + prevNtfTime[chatId] = now + return res + } + return false + } + + func registerCategories() { + logger.debug("NtfManager.registerCategories") + UNUserNotificationCenter.current().setNotificationCategories([ + UNNotificationCategory( + identifier: ntfCategoryContactRequest, + actions: [UNNotificationAction( + identifier: ntfActionAccept, + title: "Accept" + )], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "New contact request" + ), + UNNotificationCategory( + identifier: ntfCategoryContactConnected, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "Contact is connected" + ), + UNNotificationCategory( + identifier: ntfCategoryMessageReceived, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "New message" + ) + ]) + } + + func requestAuthorization(onDeny handler: (()-> Void)? = nil) { + logger.debug("NtfManager.requestAuthorization") + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .denied: + if let handler = handler { handler() } + return + case .authorized: + self.granted = true + default: + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error = error { + logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)") + } else { + self.granted = granted + } + } + } + } + center.delegate = self + } + + func notifyContactRequest(_ contactRequest: UserContactRequest) { + logger.debug("NtfManager.notifyContactRequest") + addNotification( + categoryIdentifier: ntfCategoryContactRequest, + title: "\(contactRequest.displayName) wants to connect!", + body: "Accept contact request from \(contactRequest.chatViewName)?", + targetContentIdentifier: nil, + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + ) + } + + func notifyContactConnected(_ contact: Contact) { + logger.debug("NtfManager.notifyContactConnected") + addNotification( + categoryIdentifier: ntfCategoryContactConnected, + title: "\(contact.displayName) is connected!", + body: "You can now send messages to \(contact.chatViewName)", + targetContentIdentifier: contact.id +// userInfo: ["chatId": contact.id, "contactId": contact.apiId] + ) + } + + func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { + logger.debug("NtfManager.notifyMessageReceived") + addNotification( + categoryIdentifier: ntfCategoryMessageReceived, + title: "\(cInfo.chatViewName):", + body: cItem.content.text, + targetContentIdentifier: cInfo.id +// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] + ) + } + + private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, + targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) { + if !granted { return } + let content = UNMutableNotificationContent() + content.categoryIdentifier = categoryIdentifier + content.title = title + if let s = subtitle { content.subtitle = s } + if let s = body { content.body = s } + content.targetContentIdentifier = targetContentIdentifier + content.userInfo = userInfo + content.sound = .default +// content.interruptionLevel = .active +// content.relevanceScore = 0.5 // 0-1 + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false) + let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { logger.error("addNotification error: \(error.localizedDescription)") } + } + } + + func removeNotifications(_ ids : [String]){ + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift new file mode 100644 index 0000000000..60daa0871f --- /dev/null +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -0,0 +1,630 @@ +// +// ChatAPI.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UIKit +import Dispatch +import BackgroundTasks + +private var chatController: chat_ctrl? +private let jsonDecoder = getJSONDecoder() +private let jsonEncoder = getJSONEncoder() + +enum ChatCommand { + case showActiveUser + case createActiveUser(profile: Profile) + case startChat + case apiGetChats + case apiGetChat(type: ChatType, id: Int64) + case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) + case addContact + case connect(connReq: String) + case apiDeleteChat(type: ChatType, id: Int64) + case updateProfile(profile: Profile) + case createMyAddress + case deleteMyAddress + case showMyAddress + case apiAcceptContact(contactReqId: Int64) + case apiRejectContact(contactReqId: Int64) + case string(String) + + var cmdString: String { + get { + switch self { + case .showActiveUser: return "/u" + case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" + case .startChat: return "/_start" + case .apiGetChats: return "/_get chats" + case let .apiGetChat(type, id): return "/_get chat \(type.rawValue)\(id) count=500" + case let .apiSendMessage(type, id, mc): return "/_send \(type.rawValue)\(id) \(mc.cmdString)" + case .addContact: return "/connect" + case let .connect(connReq): return "/connect \(connReq)" + case let .apiDeleteChat(type, id): return "/_delete \(type.rawValue)\(id)" + case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)" + case .createMyAddress: return "/address" + case .deleteMyAddress: return "/delete_address" + case .showMyAddress: return "/show_address" + case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" + case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .string(str): return str + } + } + } +} + +struct APIResponse: Decodable { + var resp: ChatResponse +} + +enum ChatResponse: Decodable, Error { + case response(type: String, json: String) + case activeUser(user: User) + case chatStarted + case apiChats(chats: [ChatData]) + case apiChat(chat: ChatData) + case invitation(connReqInvitation: String) + case sentConfirmation + case sentInvitation + case contactDeleted(contact: Contact) + case userProfileNoChange + case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case userContactLink(connReqContact: String) + case userContactLinkCreated(connReqContact: String) + case userContactLinkDeleted + case contactConnected(contact: Contact) + case receivedContactRequest(contactRequest: UserContactRequest) + case acceptingContactRequest(contact: Contact) + case contactRequestRejected + case contactUpdated(toContact: Contact) + case contactSubscribed(contact: Contact) + case contactDisconnected(contact: Contact) + case contactSubError(contact: Contact, chatError: ChatError) + case groupSubscribed(groupInfo: GroupInfo) + case groupEmpty(groupInfo: GroupInfo) + case userContactLinkSubscribed + case newChatItem(chatItem: AChatItem) + case chatCmdError(chatError: ChatError) + case chatError(chatError: ChatError) + + var responseType: String { + get { + switch self { + case let .response(type, _): return "* \(type)" + case .activeUser: return "activeUser" + case .chatStarted: return "chatStarted" + case .apiChats: return "apiChats" + case .apiChat: return "apiChat" + case .invitation: return "invitation" + case .sentConfirmation: return "sentConfirmation" + case .sentInvitation: return "sentInvitation" + case .contactDeleted: return "contactDeleted" + case .userProfileNoChange: return "userProfileNoChange" + case .userProfileUpdated: return "userProfileNoChange" + case .userContactLink: return "userContactLink" + case .userContactLinkCreated: return "userContactLinkCreated" + case .userContactLinkDeleted: return "userContactLinkDeleted" + case .contactConnected: return "contactConnected" + case .receivedContactRequest: return "receivedContactRequest" + case .acceptingContactRequest: return "acceptingContactRequest" + case .contactRequestRejected: return "contactRequestRejected" + case .contactUpdated: return "contactUpdated" + case .contactSubscribed: return "contactSubscribed" + case .contactDisconnected: return "contactDisconnected" + case .contactSubError: return "contactSubError" + case .groupSubscribed: return "groupSubscribed" + case .groupEmpty: return "groupEmpty" + case .userContactLinkSubscribed: return "userContactLinkSubscribed" + case .newChatItem: return "newChatItem" + case .chatCmdError: return "chatCmdError" + case .chatError: return "chatError" + } + } + } + + var details: String { + get { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case let .apiChats(chats): return String(describing: chats) + case let .apiChat(chat): return String(describing: chat) + case let .invitation(connReqInvitation): return connReqInvitation + case .sentConfirmation: return noDetails + case .sentInvitation: return noDetails + case let .contactDeleted(contact): return String(describing: contact) + case .userProfileNoChange: return noDetails + case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .userContactLink(connReq): return connReq + case let .userContactLinkCreated(connReq): return connReq + case .userContactLinkDeleted: return noDetails + case let .contactConnected(contact): return String(describing: contact) + case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) + case let .acceptingContactRequest(contact): return String(describing: contact) + case .contactRequestRejected: return noDetails + case let .contactUpdated(toContact): return String(describing: toContact) + case let .contactSubscribed(contact): return String(describing: contact) + case let .contactDisconnected(contact): return String(describing: contact) + case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" + case let .groupSubscribed(groupInfo): return String(describing: groupInfo) + case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case .userContactLinkSubscribed: return noDetails + case let .newChatItem(chatItem): return String(describing: chatItem) + case let .chatCmdError(chatError): return String(describing: chatError) + case let .chatError(chatError): return String(describing: chatError) + } + } + } + + private var noDetails: String { get { "\(responseType): no details" } } +} + +enum TerminalItem: Identifiable { + case cmd(Date, ChatCommand) + case resp(Date, ChatResponse) + + var id: Date { + get { + switch self { + case let .cmd(id, _): return id + case let .resp(id, _): return id + } + } + } + + var label: String { + get { + switch self { + case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))" + case let .resp(_, resp): return "< \(resp.responseType)" + } + } + } + + var details: String { + get { + switch self { + case let .cmd(_, cmd): return cmd.cmdString + case let .resp(_, resp): return resp.details + } + } + } +} + +func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { + var c = cmd.cmdString.cString(using: .utf8)! + let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!) + DispatchQueue.main.async { + ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) + ChatModel.shared.terminalItems.append(.resp(.now, resp)) + } + return resp +} + +func chatRecvMsg() throws -> ChatResponse { + chatResponse(chat_recv_msg(getChatCtrl())!) +} + +func apiGetActiveUser() throws -> User? { + let _ = getChatCtrl() + let r = try chatSendCmd(.showActiveUser) + switch r { + case let .activeUser(user): return user + case .chatCmdError(.error(.noActiveUser)): return nil + default: throw r + } +} + +func apiCreateActiveUser(_ p: Profile) throws -> User { + let r = try chatSendCmd(.createActiveUser(profile: p)) + if case let .activeUser(user) = r { return user } + throw r +} + +func apiStartChat() throws { + let r = try chatSendCmd(.startChat) + if case .chatStarted = r { return } + throw r +} + +func apiGetChats() throws -> [Chat] { + let r = try chatSendCmd(.apiGetChats) + if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } } + throw r +} + +func apiGetChat(type: ChatType, id: Int64) throws -> Chat { + let r = try chatSendCmd(.apiGetChat(type: type, id: id)) + if case let .apiChat(chat) = r { return Chat.init(chat) } + throw r +} + +func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) throws -> ChatItem { + let r = try chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg)) + if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem } + throw r +} + +func apiAddContact() throws -> String { + let r = try chatSendCmd(.addContact) + if case let .invitation(connReqInvitation) = r { return connReqInvitation } + throw r +} + +func apiConnect(connReq: String) throws { + let r = try chatSendCmd(.connect(connReq: connReq)) + switch r { + case .sentConfirmation: return + case .sentInvitation: return + default: throw r + } +} + +func apiDeleteChat(type: ChatType, id: Int64) throws { + let r = try chatSendCmd(.apiDeleteChat(type: type, id: id)) + if case .contactDeleted = r { return } + throw r +} + +func apiUpdateProfile(profile: Profile) throws -> Profile? { + let r = try chatSendCmd(.updateProfile(profile: profile)) + switch r { + case .userProfileNoChange: return nil + case let .userProfileUpdated(_, toProfile): return toProfile + default: throw r + } +} + +func apiCreateUserAddress() throws -> String { + let r = try chatSendCmd(.createMyAddress) + if case let .userContactLinkCreated(connReq) = r { return connReq } + throw r +} + +func apiDeleteUserAddress() throws { + let r = try chatSendCmd(.deleteMyAddress) + if case .userContactLinkDeleted = r { return } + throw r +} + +func apiGetUserAddress() throws -> String? { + let r = try chatSendCmd(.showMyAddress) + switch r { + case let .userContactLink(connReq): + return connReq + case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): + return nil + default: throw r + } +} + +func apiAcceptContactRequest(contactReqId: Int64) throws -> Contact { + let r = try chatSendCmd(.apiAcceptContact(contactReqId: contactReqId)) + if case let .acceptingContactRequest(contact) = r { return contact } + throw r +} + +func apiRejectContactRequest(contactReqId: Int64) throws { + let r = try chatSendCmd(.apiRejectContact(contactReqId: contactReqId)) + if case .contactRequestRejected = r { return } + throw r +} + +func acceptContactRequest(_ contactRequest: UserContactRequest) { + do { + let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId) + let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) + ChatModel.shared.replaceChat(contactRequest.id, chat) + } catch let error { + logger.error("acceptContactRequest error: \(error.localizedDescription)") + } +} + +func rejectContactRequest(_ contactRequest: UserContactRequest) { + do { + try apiRejectContactRequest(contactReqId: contactRequest.apiId) + ChatModel.shared.removeChat(contactRequest.id) + } catch let error { + logger.error("rejectContactRequest: \(error.localizedDescription)") + } +} + +func initializeChat() { + do { + ChatModel.shared.currentUser = try apiGetActiveUser() + } catch { + fatalError("Failed to initialize chat controller or database: \(error)") + } +} + +class ChatReceiver { + private var receiveLoop: DispatchWorkItem? + private var receiveMessages = true + private var _lastMsgTime = Date.now + + static let shared = ChatReceiver() + + var lastMsgTime: Date { get { _lastMsgTime } } + + func start() { + logger.debug("ChatReceiver.start") + receiveMessages = true + _lastMsgTime = .now + if receiveLoop != nil { return } + let loop = DispatchWorkItem(qos: .default, flags: []) { + while self.receiveMessages { + do { + processReceivedMsg(try chatRecvMsg()) + self._lastMsgTime = .now + } catch { + logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)") + } + } + } + receiveLoop = loop + DispatchQueue.global().async(execute: loop) + } + + func stop() { + logger.debug("ChatReceiver.stop") + receiveMessages = false + receiveLoop?.cancel() + receiveLoop = nil + } +} + +func processReceivedMsg(_ res: ChatResponse) { + let chatModel = ChatModel.shared + DispatchQueue.main.async { + chatModel.terminalItems.append(.resp(.now, res)) + logger.debug("processReceivedMsg: \(res.responseType)") + switch res { + case let .contactConnected(contact): + chatModel.updateContact(contact) + chatModel.updateNetworkStatus(contact, .connected) + NtfManager.shared.notifyContactConnected(contact) + case let .receivedContactRequest(contactRequest): + chatModel.addChat(Chat( + chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), + chatItems: [] + )) + NtfManager.shared.notifyContactRequest(contactRequest) + case let .contactUpdated(toContact): + let cInfo = ChatInfo.direct(contact: toContact) + if chatModel.hasChat(toContact.id) { + chatModel.updateChatInfo(cInfo) + } + case let .contactSubscribed(contact): + chatModel.updateContact(contact) + chatModel.updateNetworkStatus(contact, .connected) + case let .contactDisconnected(contact): + chatModel.updateContact(contact) + chatModel.updateNetworkStatus(contact, .disconnected) + case let .contactSubError(contact, chatError): + chatModel.updateContact(contact) + var err: String + switch chatError { + case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network" + case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" + default: err = String(describing: chatError) + } + chatModel.updateNetworkStatus(contact, .error(err)) + case let .newChatItem(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + chatModel.addChatItem(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + default: + logger.debug("unsupported event: \(res.responseType)") + } + } +} + +private struct UserResponse: Decodable { + var user: User? + var error: String? +} + +private func chatResponse(_ cjson: UnsafePointer) -> ChatResponse { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// TODO is there a way to do it without copying the data? e.g: +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + +// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? + + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { + type = j1.allKeys[0] as? String + } + json = prettyJSON(j) + } + return ChatResponse.response(type: type ?? "invalid", json: json ?? s) +} + +func prettyJSON(_ obj: NSDictionary) -> String? { + if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { + return String(decoding: d, as: UTF8.self) + } + return nil +} + +private func getChatCtrl() -> chat_ctrl { + if let controller = chatController { return controller } + let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" + var cstr = dataDir.cString(using: .utf8)! + chatController = chat_init(&cstr) + return chatController! +} + +private func decodeCJSON(_ cjson: UnsafePointer) -> T? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + return try? jsonDecoder.decode(T.self, from: d) +} + +private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! + return try? JSONSerialization.jsonObject(with: d) as? NSDictionary +} + +private func encodeCJSON(_ value: T) -> [CChar] { + let data = try! jsonEncoder.encode(value) + let str = String(decoding: data, as: UTF8.self) + return str.cString(using: .utf8)! +} + +enum ChatError: Decodable { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) +} + +enum ChatErrorType: Decodable { + case noActiveUser + case activeUserExists + case chatNotStarted + case invalidConnReq + case invalidChatMessage(message: String) + case contactGroups(contact: Contact, groupNames: [GroupName]) + case groupUserRole + case groupContactRole(contactName: ContactName) + case groupDuplicateMember(contactName: ContactName) + case groupDuplicateMemberId + case groupNotJoined(groupInfo: GroupInfo) + case groupMemberNotActive + case groupMemberUserRemoved + case groupMemberNotFound(contactName: ContactName) + case groupMemberIntroNotFound(contactName: ContactName) + case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) + case groupInternal(message: String) + case fileNotFound(message: String) + case fileAlreadyReceiving(message: String) + case fileAlreadyExists(filePath: String) + case fileRead(filePath: String, message: String) + case fileWrite(filePath: String, message: String) + case fileSend(fileId: Int64, agentError: String) + case fileRcvChunk(message: String) + case fileInternal(message: String) + case agentVersion + case commandError(message: String) +} + +enum StoreError: Decodable { + case duplicateName + case contactNotFound(contactId: Int64) + case contactNotFoundByName(contactName: ContactName) + case contactNotReady(contactName: ContactName) + case duplicateContactLink + case userContactLinkNotFound + case contactRequestNotFound(contactRequestId: Int64) + case contactRequestNotFoundByName(contactName: ContactName) + case groupNotFound(groupId: Int64) + case groupNotFoundByName(groupName: GroupName) + case groupWithoutUser + case duplicateGroupMember + case groupAlreadyJoined + case groupInvitationNotFound + case sndFileNotFound(fileId: Int64) + case sndFileInvalid(fileId: Int64) + case rcvFileNotFound(fileId: Int64) + case fileNotFound(fileId: Int64) + case rcvFileInvalid(fileId: Int64) + case connectionNotFound(agentConnId: String) + case introNotFound + case uniqueID + case internalError(message: String) + case noMsgDelivery(connId: Int64, agentMsgId: String) + case badChatItem(itemId: Int64) + case chatItemNotFound(itemId: Int64) +} + +enum AgentErrorType: Decodable { + case CMD(cmdErr: CommandErrorType) + case CONN(connErr: ConnectionErrorType) + case SMP(smpErr: SMPErrorType) + case BROKER(brokerErr: BrokerErrorType) + case AGENT(agentErr: SMPAgentError) + case INTERNAL(internalErr: String) +} + +enum CommandErrorType: Decodable { + case PROHIBITED + case SYNTAX + case NO_CONN + case SIZE + case LARGE +} + +enum ConnectionErrorType: Decodable { + case NOT_FOUND + case DUPLICATE + case SIMPLEX + case NOT_ACCEPTED + case NOT_AVAILABLE +} + +enum BrokerErrorType: Decodable { + case RESPONSE(smpErr: SMPErrorType) + case UNEXPECTED + case NETWORK + case TRANSPORT(transportErr: SMPTransportError) + case TIMEOUT +} + +enum SMPErrorType: Decodable { + case BLOCK + case SESSION + case CMD(cmdErr: SMPCommandError) + case AUTH + case QUOTA + case NO_MSG + case LARGE_MSG + case INTERNAL +} + +enum SMPCommandError: Decodable { + case UNKNOWN + case SYNTAX + case NO_AUTH + case HAS_AUTH + case NO_QUEUE +} + +enum SMPTransportError: Decodable { + case TEBadBlock + case TELargeMsg + case TEBadSession + case TEHandshake(handshakeErr: SMPHandshakeError) +} + +enum SMPHandshakeError: Decodable { + case PARSE + case VERSION + case IDENTITY +} + +enum SMPAgentError: Decodable { + case A_MESSAGE + case A_PROHIBITED + case A_VERSION + case A_ENCRYPTION +} diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift new file mode 100644 index 0000000000..eb0b6af432 --- /dev/null +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -0,0 +1,32 @@ +import Foundation + +var greeting = "Hello, playground" + +let jsonEncoder = JSONEncoder() + +//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!) + + +var a = [1, 2, 3] + +a.removeAll(where: { $0 == 1} ) + +print(a) + +let input = "This is a test with the привет 🙂 URL https://www.hackingwithswift.com to be detected." +let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) +let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.count)) + +print(matches) + +for match in matches { + guard let range = Range(match.range, in: input) else { continue } + let url = input[range] + print(url) +} + +let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$") + +print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil) + +let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT") diff --git a/apps/ios/Shared/MyPlayground.playground/contents.xcplayground b/apps/ios/Shared/MyPlayground.playground/contents.xcplayground new file mode 100644 index 0000000000..cf026f2286 --- /dev/null +++ b/apps/ios/Shared/MyPlayground.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline new file mode 100644 index 0000000000..f62b952eff --- /dev/null +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -0,0 +1,11 @@ + + + + + + + diff --git a/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h new file mode 100644 index 0000000000..bc28b42d38 --- /dev/null +++ b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h @@ -0,0 +1,11 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +extern void hs_init(int argc, char **argv[]); + +typedef void* chat_ctrl; + +extern chat_ctrl chat_init(char *path); +extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); +extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h new file mode 100644 index 0000000000..bc28b42d38 --- /dev/null +++ b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h @@ -0,0 +1,11 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +extern void hs_init(int argc, char **argv[]); + +typedef void* chat_ctrl; + +extern chat_ctrl chat_init(char *path); +extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); +extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift new file mode 100644 index 0000000000..2334c70e76 --- /dev/null +++ b/apps/ios/Shared/SimpleXApp.swift @@ -0,0 +1,43 @@ +// +// SimpleXApp.swift +// Shared +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import SwiftUI +import OSLog + +let logger = Logger() + +@main +struct SimpleXApp: App { + @StateObject private var chatModel = ChatModel.shared + @Environment(\.scenePhase) var scenePhase + + init() { + hs_init(0, nil) + BGManager.shared.register() + NtfManager.shared.registerCategories() + } + + var body: some Scene { + return WindowGroup { + ContentView() + .environmentObject(chatModel) + .onOpenURL { url in + logger.debug("ContentView.onOpenURL: \(url)") + chatModel.appOpenUrl = url + chatModel.connectViaUrl = true + } + .onAppear() { + initializeChat() + } + .onChange(of: scenePhase) { phase in + if phase == .background { + BGManager.shared.schedule() + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift new file mode 100644 index 0000000000..9799db243f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -0,0 +1,97 @@ +// +// ChatInfoView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 05/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatInfoView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var chat: Chat + @Binding var showChatInfo: Bool + @State private var showDeleteContactAlert = false + @State private var alertContact: Contact? + @State private var showNetworkStatusInfo = false + + var body: some View { + VStack{ + ChatInfoImage(chat: chat) + .frame(width: 192, height: 192) + .padding(.top, 48) + .padding() + Text(chat.chatInfo.localDisplayName).font(.largeTitle) + .padding(.bottom, 2) + Text(chat.chatInfo.fullName).font(.title) + .padding(.bottom) + + if case let .direct(contact) = chat.chatInfo { + VStack { + HStack { + Button { + showNetworkStatusInfo.toggle() + } label: { + serverImage() + Text(chat.serverInfo.networkStatus.statusString) + .foregroundColor(.primary) + } + } + if showNetworkStatusInfo { + Text(chat.serverInfo.networkStatus.statusExplanation) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal, 64) + .padding(.vertical, 8) + } + + Spacer() + Button(role: .destructive) { + alertContact = contact + showDeleteContactAlert = true + } label: { + Label("Delete contact", systemImage: "trash") + } + .padding() + .alert(isPresented: $showDeleteContactAlert) { + deleteContactAlert(alertContact!) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + func serverImage() -> some View { + let status = chat.serverInfo.networkStatus + return Image(systemName: status.imageName) + .foregroundColor(status == .connected ? .green : .secondary) + } + + private func deleteContactAlert(_ contact: Contact) -> Alert { + Alert( + title: Text("Delete contact?"), + message: Text("Contact and all messages will be deleted"), + primaryButton: .destructive(Text("Delete")) { + do { + try apiDeleteChat(type: .direct, id: contact.apiId) + chatModel.removeChat(contact.id) + showChatInfo = false + } catch let error { + logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") + } + alertContact = nil + }, secondaryButton: .cancel() { + alertContact = nil + } + ) + } +} + +struct ChatInfoView_Previews: PreviewProvider { + static var previews: some View { + @State var showChatInfo = true + return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showChatInfo: $showChatInfo) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift new file mode 100644 index 0000000000..2ee4a93e24 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -0,0 +1,43 @@ +// +// EmojiItemView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 04/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct EmojiItemView: View { + var chatItem: ChatItem + + var body: some View { + let sent = chatItem.chatDir.sent + + VStack { + Text(chatItem.content.text.trimmingCharacters(in: .whitespaces)) + .font(emojiFont) + .padding(.top, 8) + .padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) + Text(getDateFormatter().string(from: chatItem.meta.itemTs)) + .font(.caption) + .foregroundColor(.secondary) + .padding(.bottom, 8) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) + } + .padding(.horizontal) + .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) + } +} + +struct EmojiItemView_Previews: PreviewProvider { + static var previews: some View { + Group{ + EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) + EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) + } + .previewLayout(.fixed(width: 360, height: 70)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift new file mode 100644 index 0000000000..0a396b16fc --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -0,0 +1,144 @@ +// +// TextItemView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 04/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", options: .caseInsensitive) + +private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$") + +private let sentColorLigth = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) +private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) +private let linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) + +struct TextItemView: View { + @Environment(\.colorScheme) var colorScheme + var chatItem: ChatItem + var width: CGFloat + private let codeFont = Font.custom("Courier", size: UIFont.preferredFont(forTextStyle: .body).pointSize) + + var body: some View { + let sent = chatItem.chatDir.sent +// let minWidth = min(200, width) + let maxWidth = width * 0.78 + let meta = getDateFormatter().string(from: chatItem.meta.itemTs) + + return ZStack(alignment: .bottomTrailing) { + (messageText(chatItem) + reserveSpaceForMeta(meta)) + .padding(.top, 6) + .padding(.bottom, 7) + .padding(.horizontal, 12) + .frame(minWidth: 0, alignment: .leading) +// .foregroundColor(sent ? .white : .primary) + .textSelection(.enabled) + + Text(meta) + .font(.caption) + .foregroundColor(.secondary) +// .foregroundColor(sent ? Color(uiColor: .secondarySystemBackground) : .secondary) + .padding(.bottom, 4) + .padding(.horizontal, 12) + } +// .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) + .background( + sent + ? (colorScheme == .light ? sentColorLigth : sentColorDark) + : Color(uiColor: .tertiarySystemGroupedBackground) + ) + .cornerRadius(18) + .padding(.horizontal) + .frame( + maxWidth: maxWidth, + maxHeight: .infinity, + alignment: sent ? .trailing : .leading + ) + } + + private func messageText(_ chatItem: ChatItem) -> Text { + let s = chatItem.content.text + var res: Text + if s == "" { + res = Text("") + } else { + let parts = s.split(separator: " ") + res = wordToText(parts[0]) + var i = 1 + while i < parts.count { + res = res + Text(" ") + wordToText(parts[i]) + i = i + 1 + } + } + if case let .groupRcv(groupMember) = chatItem.chatDir { + let member = Text(groupMember.memberProfile.displayName).font(.headline) + return member + Text(": ") + res + } else { + return res + } + } + + private func reserveSpaceForMeta(_ meta: String) -> Text { + Text(AttributedString(" \(meta)", attributes: AttributeContainer([ + .font: UIFont.preferredFont(forTextStyle: .caption1) as Any, + .foregroundColor: UIColor.clear as Any, + ]))) + } + + private func wordToText(_ s: String.SubSequence) -> Text { + let str = String(s) + switch true { + case s.starts(with: "http://") || s.starts(with: "https://"): + return linkText(str, prefix: "") + case match(str, emailRegex): + return linkText(str, prefix: "mailto:") + case match(str, phoneRegex): + return linkText(str, prefix: "tel:") + default: + if (s.count > 1) { + switch true { + case s.first == "*" && s.last == "*": return mdText(s).bold() + case s.first == "_" && s.last == "_": return mdText(s).italic() + case s.first == "+" && s.last == "+": return mdText(s).underline() + case s.first == "~" && s.last == "~": return mdText(s).strikethrough() + default: return Text(s) + } + } else { + return Text(s) + } + } + } + + private func match(_ s: String, _ regex: NSRegularExpression) -> Bool { + regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil + } + + private func linkText(_ s: String, prefix: String) -> Text { + Text(AttributedString(s, attributes: AttributeContainer([ + .link: NSURL(string: prefix + s) as Any, + .foregroundColor: linkColor as Any + ]))).underline() + } + + private func mdText(_ s: String.SubSequence) -> Text { + Text(s[s.index(s.startIndex, offsetBy: 1).. DateFormatter { + if let df = dateFormatter { return df } + let df = DateFormatter() + df.dateFormat = "HH:mm" + dateFormatter = df + return df +} + +struct ChatItemView_Previews: PreviewProvider { + static var previews: some View { + Group{ + ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360) + ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), width: 360) + ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), width: 360) + ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), width: 360) + ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), width: 360) + } + .previewLayout(.fixed(width: 360, height: 70)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift new file mode 100644 index 0000000000..5eda7af1c3 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -0,0 +1,136 @@ +// +// ChatView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9) +private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 ) + +struct ChatView: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat + @State private var inProgress: Bool = false + @FocusState private var keyboardVisible: Bool + @State private var showChatInfo = false + + var body: some View { + let cInfo = chat.chatInfo + + return VStack { + GeometryReader { g in + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 5) { + ForEach(chatModel.chatItems, id: \.id) { + ChatItemView(chatItem: $0, width: g.size.width) + .frame(minWidth: 0, maxWidth: .infinity, alignment: $0.chatDir.sent ? .trailing : .leading) + } + .onAppear { scrollToBottom(proxy) } + .onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) } + .onChange(of: keyboardVisible) { _ in + if keyboardVisible { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + scrollToBottom(proxy, animation: .easeInOut(duration: 1)) + } + } + } + } + } + .coordinateSpace(name: "scrollView") + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + } + + Spacer(minLength: 0) + + SendMessageView( + sendMessage: sendMessage, + inProgress: inProgress, + keyboardVisible: $keyboardVisible + ) + } + .navigationTitle(cInfo.chatViewName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { chatModel.chatId = nil } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.backward") + Text("Chats") + } + } + } + ToolbarItem(placement: .principal) { + Button { + showChatInfo = true + } label: { + HStack { + ChatInfoImage( + chat: chat, + color: colorScheme == .dark + ? chatImageColorDark + : chatImageColorLight + ) + .frame(width: 32, height: 32) + .padding(.trailing, 4) + VStack { + Text(cInfo.displayName).font(.headline) + if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName { + Text(cInfo.fullName).font(.subheadline) + } + } + } + .foregroundColor(.primary) + } + .sheet(isPresented: $showChatInfo) { + ChatInfoView(chat: chat, showChatInfo: $showChatInfo) + } + } + } + .navigationBarBackButtonHidden(true) + } + + func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { + if let id = chatModel.chatItems.last?.id { + withAnimation(animation) { + proxy.scrollTo(id, anchor: .bottom) + } + } + } + + func sendMessage(_ msg: String) { + do { + let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg)) + chatModel.addChatItem(chat.chatInfo, chatItem) + } catch { + logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)") + } + } +} + +struct ChatView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chatId = "@1" + chatModel.chatItems = [ + ChatItem.getSample(1, .directSnd, .now, "hello"), + ChatItem.getSample(2, .directRcv, .now, "hi"), + ChatItem.getSample(3, .directRcv, .now, "hi there"), + ChatItem.getSample(4, .directRcv, .now, "hello again"), + ChatItem.getSample(5, .directSnd, .now, "hi there!!!"), + ChatItem.getSample(6, .directSnd, .now, "how are you?"), + ChatItem.getSample(7, .directSnd, .now, "👍👍👍👍"), + ChatItem.getSample(8, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") + ] + return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Chat/Emoji.swift b/apps/ios/Shared/Views/Chat/Emoji.swift new file mode 100644 index 0000000000..479336395e --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Emoji.swift @@ -0,0 +1,30 @@ +// +// Emoji.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 04/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +private func isSimpleEmoji(_ c: Character) -> Bool { + guard let firstScalar = c.unicodeScalars.first else { return false } + return firstScalar.properties.isEmoji && firstScalar.value > 0x238C +} + +private func isCombinedIntoEmoji(_ c: Character) -> Bool { + c.unicodeScalars.count > 1 && c.unicodeScalars.first?.properties.isEmoji ?? false +} + +func isEmoji(_ c: Character) -> Bool { + isSimpleEmoji(c) || isCombinedIntoEmoji(c) +} + +func isShortEmoji(_ str: String) -> Bool { + let s = str.trimmingCharacters(in: .whitespaces) + return s.count > 0 && s.count <= 4 && s.allSatisfy(isEmoji) +} + +let emojiFont = Font.custom("Emoji", size: 48, relativeTo: .largeTitle) diff --git a/apps/ios/Shared/Views/Chat/SendMessageView.swift b/apps/ios/Shared/Views/Chat/SendMessageView.swift new file mode 100644 index 0000000000..60e9144568 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/SendMessageView.swift @@ -0,0 +1,95 @@ +// +// SendMessageView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SendMessageView: View { + var sendMessage: (String) -> Void + var inProgress: Bool = false + @State private var message: String = "" //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + @Namespace var namespace + @FocusState.Binding var keyboardVisible: Bool + @State private var teHeight: CGFloat = 42 + @State private var teFont: Font = .body + var maxHeight: CGFloat = 360 + var minHeight: CGFloat = 37 + + var body: some View { + ZStack { + HStack(alignment: .bottom) { + ZStack(alignment: .leading) { + Text(message) + .font(teFont) + .foregroundColor(.clear) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .matchedGeometryEffect(id: "te", in: namespace) + .background(GeometryReader(content: updateHeight)) + TextEditor(text: $message) + .onSubmit(submit) + .focused($keyboardVisible) + .font(teFont) + .textInputAutocapitalization(.sentences) + .padding(.horizontal, 5) + .allowsTightening(false) + .frame(height: teHeight) + } + + if (inProgress) { + ProgressView() + .scaleEffect(1.4) + .frame(width: 31, height: 31, alignment: .center) + .padding([.bottom, .trailing], 3) + } else { + Button(action: submit) { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .foregroundColor(.accentColor) + } + .disabled(message.isEmpty) + .frame(width: 29, height: 29) + .padding([.bottom, .trailing], 4) + } + } + + RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) + .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) + .frame(height: teHeight) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + func submit() { + sendMessage(message) + message = "" + } + + func updateHeight(_ g: GeometryProxy) -> Color { + DispatchQueue.main.async { + teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) + teFont = isShortEmoji(message) ? emojiFont : .body + } + return Color.clear + } +} + +struct SendMessageView_Previews: PreviewProvider { + static var previews: some View { + @FocusState var keyboardVisible: Bool + + return VStack { + Text("") + Spacer(minLength: 0) + SendMessageView( + sendMessage: { print ($0) }, + keyboardVisible: $keyboardVisible + ) + } + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift new file mode 100644 index 0000000000..c1b5e1492c --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -0,0 +1,66 @@ +// +// ChatHelp.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 10/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatHelp: View { + @EnvironmentObject var chatModel: ChatModel + @Binding var showSettings: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Thank you for installing SimpleX Chat!") + + HStack(spacing: 4) { + Text("You can") + Button("connect to SimpleX team.") { + showSettings = false + DispatchQueue.main.async { + UIApplication.shared.open(simplexTeamURL) + } + } + } + + VStack(alignment: .leading, spacing: 10) { + Text("To start a new chat") + .font(.title2) + .fontWeight(.bold) + + HStack(spacing: 8) { + Text("Tap button ") + NewChatButton() + Text("above, then:") + } + + Text("**Add new contact**: to create your one-time QR Code for your contact.") + Text("**Scan QR code**: to connect to your contact who shows QR code to you.") + } + .padding(.top, 24) + + VStack(alignment: .leading, spacing: 10) { + Text("To connect via link") + .font(.title2) + .fontWeight(.bold) + + Text("If you received SimpleX Chat invitation link you can open it in your browser:") + + Text("💻 desktop: scan displayed QR code from the app, via **Scan QR code**.") + Text("📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.") + } + .padding(.top, 24) + } + .padding() + } +} + +struct ChatHelp_Previews: PreviewProvider { + static var previews: some View { + @State var showSettings = false + return ChatHelp(showSettings: $showSettings) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift new file mode 100644 index 0000000000..f707660477 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -0,0 +1,173 @@ +// +// ChatListNavLink.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 01/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatListNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State var chat: Chat + + @State private var showDeleteContactAlert = false + @State private var showDeleteGroupAlert = false + @State private var showContactRequestAlert = false + @State private var showContactRequestDialog = false + @State private var alertContact: Contact? + @State private var alertGroupInfo: GroupInfo? + @State private var alertContactRequest: UserContactRequest? + + var body: some View { + switch chat.chatInfo { + case let .direct(contact): + contactNavLink(contact) + case let .group(groupInfo): + groupNavLink(groupInfo) + case let .contactRequest(cReq): + contactRequestNavLink(cReq) + } + } + + private func chatView() -> some View { + ChatView(chat: chat) + .onAppear { + do { + let cInfo = chat.chatInfo + let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId) + chatModel.updateChatInfo(chat.chatInfo) + chatModel.chatItems = chat.chatItems + } catch { + logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)") + } + } + } + + private func contactNavLink(_ contact: Contact) -> some View { + NavigationLink( + tag: chat.chatInfo.id, + selection: $chatModel.chatId, + destination: { chatView() }, + label: { ChatPreviewView(chat: chat) } + ) + .disabled(!contact.ready) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + alertContact = contact + showDeleteContactAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } + .alert(isPresented: $showDeleteContactAlert) { + deleteContactAlert(alertContact!) + } + .frame(height: 80) + } + + private func groupNavLink(_ groupInfo: GroupInfo) -> some View { + NavigationLink( + tag: chat.chatInfo.id, + selection: $chatModel.chatId, + destination: { chatView() }, + label: { ChatPreviewView(chat: chat) } + ) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + alertGroupInfo = groupInfo + showDeleteGroupAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } + .alert(isPresented: $showDeleteGroupAlert) { + deleteGroupAlert(alertGroupInfo!) + } + .frame(height: 80) + } + + private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { + ContactRequestView(contactRequest: contactRequest) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { acceptContactRequest(contactRequest) } + label: { Label("Accept", systemImage: "checkmark") } + .tint(.blue) + Button(role: .destructive) { + alertContactRequest = contactRequest + showContactRequestAlert = true + } label: { + Label("Reject", systemImage: "multiply") + } + } + .alert(isPresented: $showContactRequestAlert) { + contactRequestAlert(alertContactRequest!) + } + .frame(height: 80) + .onTapGesture { showContactRequestDialog = true } + .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { + Button("Accept contact") { acceptContactRequest(contactRequest) } + Button("Reject contact (sender NOT notified)") { rejectContactRequest(contactRequest) } + } + } + + private func deleteContactAlert(_ contact: Contact) -> Alert { + Alert( + title: Text("Delete contact?"), + message: Text("Contact and all messages will be deleted"), + primaryButton: .destructive(Text("Delete")) { + do { + try apiDeleteChat(type: .direct, id: contact.apiId) + chatModel.removeChat(contact.id) + } catch let error { + logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") + } + alertContact = nil + }, secondaryButton: .cancel() { + alertContact = nil + } + ) + } + + private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { + Alert( + title: Text("Delete group"), + message: Text("Group deletion is not supported") + ) + } + + private func contactRequestAlert(_ contactRequest: UserContactRequest) -> Alert { + Alert( + title: Text("Reject contact request"), + message: Text("The sender will NOT be notified"), + primaryButton: .destructive(Text("Reject")) { + rejectContactRequest(contactRequest) + alertContactRequest = nil + }, secondaryButton: .cancel { + alertContactRequest = nil + } + ) + } +} + +struct ChatListNavLink_Previews: PreviewProvider { + static var previews: some View { + @State var chatId: String? = "@1" + return Group { + ChatListNavLink(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + )) + ChatListNavLink(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + )) + ChatListNavLink(chat: Chat( + chatInfo: ChatInfo.sampleData.contactRequest, + chatItems: [] + )) + } + .previewLayout(.fixed(width: 360, height: 80)) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift new file mode 100644 index 0000000000..af43f7badc --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -0,0 +1,119 @@ +// +// ChatListView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatListView: View { + @EnvironmentObject var chatModel: ChatModel + @State private var connectAlert = false + @State private var connectError: Error? + // not really used in this view + @State private var showSettings = false + + var user: User + + var body: some View { + NavigationView { + List { + if chatModel.chats.isEmpty { + VStack(alignment: .leading) { + ChatHelp(showSettings: $showSettings) + HStack { + Text("This text is available in settings") + SettingsButton() + } + .padding(.leading) + } + } + ForEach(chatModel.chats) { chat in + ChatListNavLink(chat: chat) + } + } + .offset(x: -8) + .listStyle(.plain) + .navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + SettingsButton() + } + ToolbarItem(placement: .navigationBarTrailing) { + NewChatButton() + } + } + .alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() } + } + .navigationViewStyle(.stack) + .alert(isPresented: $connectAlert) { connectionErrorAlert() } + } + + private func connectViaUrlAlert() -> Alert { + logger.debug("ChatListView.connectViaUrlAlert") + if let url = chatModel.appOpenUrl { + var path = url.path + logger.debug("ChatListView.connectViaUrlAlert path: \(path)") + if (path == "/contact" || path == "/invitation") { + path.removeFirst() + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + return Alert( + title: Text("Connect via \(path) link?"), + message: Text("Your profile will be sent to the contact that you received this link from: \(link)"), + primaryButton: .default(Text("Connect")) { + do { + try apiConnect(connReq: link) + } catch { + connectAlert = true + connectError = error + logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(error.localizedDescription)") + } + chatModel.appOpenUrl = nil + }, secondaryButton: .cancel() { + chatModel.appOpenUrl = nil + } + ) + } else { + return Alert(title: Text("Error: URL is invalid")) + } + } else { + return Alert(title: Text("Error: URL not available")) + } + } + + private func connectionErrorAlert() -> Alert { + Alert( + title: Text("Connection error"), + message: Text(connectError?.localizedDescription ?? "") + ) + } +} + +struct ChatListView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chats = [ + Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + ), + Chat( + chatInfo: ChatInfo.sampleData.group, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")] + ), + Chat( + chatInfo: ChatInfo.sampleData.contactRequest, + chatItems: [] + ) + + ] + return Group { + ChatListView(user: User.sampleData) + .environmentObject(chatModel) + ChatListView(user: User.sampleData) + .environmentObject(ChatModel()) + } + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift new file mode 100644 index 0000000000..741b55e9a2 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -0,0 +1,91 @@ +// +// ChatNavLabel.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 28/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatPreviewView: View { + @ObservedObject var chat: Chat + @Environment(\.colorScheme) var colorScheme + var darkGreen = Color(red: 0, green: 0.5, blue: 0) + + var body: some View { + let cItem = chat.chatItems.last + return HStack(spacing: 8) { + ZStack(alignment: .bottomLeading) { + ChatInfoImage(chat: chat) + .frame(width: 63, height: 63) + if case .direct = chat.chatInfo, + chat.serverInfo.networkStatus == .connected { + Image(systemName: "circle.fill") + .resizable() + .foregroundColor(colorScheme == .dark ? darkGreen : .green) + .frame(width: 5, height: 5) + .padding([.bottom, .leading], 1) + } + } + .padding(.leading, 4) + + VStack(spacing: 0) { + HStack(alignment: .top) { + Text(chat.chatInfo.chatViewName) + .font(.title3) + .fontWeight(.bold) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + Text(getDateFormatter().string(from: cItem?.meta.itemTs ?? chat.chatInfo.createdAt)) + .font(.subheadline) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(.secondary) + } + .padding(.top, 4) + .padding(.horizontal, 8) + + if let cItem = cItem { + Text(chatItemText(cItem)) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + } + else if case let .direct(contact) = chat.chatInfo, !contact.ready { + Text("Connecting...") + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + } + } + } + } + + private func chatItemText(_ cItem: ChatItem) -> String { + let t = cItem.content.text + if case let .groupRcv(groupMember) = cItem.chatDir { + return groupMember.memberProfile.displayName + ": " + t + } + return t + } +} + +struct ChatPreviewView_Previews: PreviewProvider { + static var previews: some View { + Group { + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [] + )) + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + )) + ChatPreviewView(chat: Chat( + chatInfo: ChatInfo.sampleData.group, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")] + )) + } + .previewLayout(.fixed(width: 360, height: 78)) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift new file mode 100644 index 0000000000..2acd47c707 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -0,0 +1,53 @@ +// +// ContactRequestView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 02/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ContactRequestView: View { + var contactRequest: UserContactRequest + + var body: some View { + return HStack(spacing: 8) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundColor(Color(uiColor: .secondarySystemBackground)) + .frame(width: 63, height: 63) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.blue) + .padding(.leading, 8) + .padding(.top, 4) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + Text(getDateFormatter().string(from: contactRequest.createdAt)) + .font(.subheadline) + .padding(.trailing, 28) + .padding(.top, 4) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(.secondary) + } + Text("wants to connect to you!") + .frame(minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + .padding(.top, 1) + } + } + } +} + +struct ContactRequestView_Previews: PreviewProvider { + static var previews: some View { + ContactRequestView(contactRequest: UserContactRequest.sampleData) + .previewLayout(.fixed(width: 360, height: 80)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift new file mode 100644 index 0000000000..6f95a9be97 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -0,0 +1,37 @@ +// +// ChatInfoImage.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 05/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatInfoImage: View { + @ObservedObject var chat: Chat + var color = Color(uiColor: .tertiarySystemGroupedBackground) + + var body: some View { + var iconName: String + switch chat.chatInfo { + case .direct: iconName = "person.crop.circle.fill" + case .group: iconName = "person.2.circle.fill" + default: iconName = "circle.fill" + } + + return Image(systemName: iconName) + .resizable() + .foregroundColor(color) + } +} + +struct ChatInfoImage_Previews: PreviewProvider { + static var previews: some View { + ChatInfoImage( + chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) + , color: Color(red: 0.9, green: 0.9, blue: 0.9) + ) + .previewLayout(.fixed(width: 63, height: 63)) + } +} diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift new file mode 100644 index 0000000000..0f0b1521c1 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -0,0 +1,43 @@ +// +// AddContactView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import CoreImage.CIFilterBuiltins + +struct AddContactView: View { + var connReqInvitation: String + @State private var shareInvitation = false + + var body: some View { + VStack { + Text("Add contact") + .font(.title) + .padding(.bottom) + Text("Show QR code to your contact\nto scan from the app") + .font(.title2) + .multilineTextAlignment(.center) + QRCode(uri: connReqInvitation) + .padding() + Text("If you can't show QR code, you can share the invitation link via any channel") + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button { shareInvitation = true } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + .padding() + .shareSheet(isPresented: $shareInvitation, items: [connReqInvitation]) + } + } +} + +struct AddContactView_Previews: PreviewProvider { + static var previews: some View { + AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D") + } +} diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift new file mode 100644 index 0000000000..024c310434 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift @@ -0,0 +1,54 @@ +// +// ConnectContactView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import CodeScanner + +struct ConnectContactView: View { + var completed: ((Error?) -> Void) + + var body: some View { + VStack { + Text("Scan QR code") + .font(.title) + .padding(.bottom) + Text("Your chat profile will be sent to your contact.") + .font(.title2) + .multilineTextAlignment(.center) + .padding() + ZStack { + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .border(.gray) + } + .padding(13.0) + } + } + + func processQRCode(_ resp: Result) { + switch resp { + case let .success(r): + do { + try apiConnect(connReq: r.string) + completed(nil) + } catch { + logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)") + completed(error) + } + case let .failure(e): + logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") + completed(e) + } + } +} + +struct ConnectContactView_Previews: PreviewProvider { + static var previews: some View { + return ConnectContactView(completed: {_ in }) + } +} diff --git a/apps/ios/Shared/Views/NewChat/CreateGroupView.swift b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift new file mode 100644 index 0000000000..89a65f1ecd --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift @@ -0,0 +1,21 @@ +// +// CreateGroupView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct CreateGroupView: View { + var body: some View { + Text("CreateGroupView") + } +} + +struct CreateGroupView_Previews: PreviewProvider { + static var previews: some View { + CreateGroupView() + } +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift new file mode 100644 index 0000000000..0064ac9292 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -0,0 +1,80 @@ +// +// NewChatButton.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct NewChatButton: View { + @State private var showAddChat = false + @State private var addContact = false + @State private var addContactAlert = false + @State private var addContactError: Error? + @State private var connReqInvitation: String = "" + @State private var connectContact = false + @State private var connectAlert = false + @State private var connectError: Error? + @State private var createGroup = false + + var body: some View { + Button { showAddChat = true } label: { + Image(systemName: "person.crop.circle.badge.plus") + } + .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { + Button("Add contact") { addContactAction() } + Button("Scan QR code") { connectContact = true } + Button("Create group") { createGroup = true } + .disabled(true) + } + .sheet(isPresented: $addContact, content: { + AddContactView(connReqInvitation: connReqInvitation) + }) + .alert(isPresented: $addContactAlert) { + connectionError(addContactError) + } + .sheet(isPresented: $connectContact, content: { + connectContactSheet() + }) + .alert(isPresented: $connectAlert) { + connectionError(connectError) + } + .sheet(isPresented: $createGroup, content: { CreateGroupView() }) + } + + func addContactAction() { + do { + connReqInvitation = try apiAddContact() + addContact = true + } catch { + addContactAlert = true + addContactError = error + logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") + } + } + + func connectContactSheet() -> some View { + ConnectContactView(completed: { err in + connectContact = false + if err != nil { + connectAlert = true + connectError = err + } + }) + } + + func connectionError(_ error: Error?) -> Alert { + Alert( + title: Text("Connection error"), + message: Text(error?.localizedDescription ?? "") + ) + } +} + +struct NewChatButton_Previews: PreviewProvider { + static var previews: some View { + NewChatButton() + } +} diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift new file mode 100644 index 0000000000..4d9b7835b0 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -0,0 +1,51 @@ +// +// QRCode.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 30/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import CoreImage.CIFilterBuiltins + +struct QRCode: View { + let uri: String + @State private var image: UIImage? + + var body: some View { + ZStack { + if let image = image { + Image(uiImage: image) + .resizable() + .interpolation(.none) + .aspectRatio(1, contentMode: .fit) + .textSelection(.enabled) + } + } + .onAppear { + generateImage() + } + } + + private func generateImage() { + guard image == nil else { return } + + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(uri.utf8) + + guard + let outputImage = filter.outputImage, + let cgImage = context.createCGImage(outputImage, from: outputImage.extent) + else { return } + + self.image = UIImage(cgImage: cgImage) + } +} + +struct QRCode_Previews: PreviewProvider { + static var previews: some View { + QRCode(uri: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D") + } +} diff --git a/apps/ios/Shared/Views/NewChat/ShareSheet.swift b/apps/ios/Shared/Views/NewChat/ShareSheet.swift new file mode 100644 index 0000000000..3b9dbcb5e1 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/ShareSheet.swift @@ -0,0 +1,40 @@ +// +// ShareSheet.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 30/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +extension UIApplication { + static let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first + static let keyWindowScene = shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene +} + +extension View { + func shareSheet(isPresented: Binding, items: [Any]) -> some View { + guard isPresented.wrappedValue else { return self } + let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) + let presentedViewController = UIApplication.keyWindow?.rootViewController?.presentedViewController ?? UIApplication.keyWindow?.rootViewController + activityViewController.completionWithItemsHandler = { _, _, _, _ in isPresented.wrappedValue = false } + presentedViewController?.present(activityViewController, animated: true) + return self + } +} + +struct ShareSheetTest: View { + @State private var isPresentingShareSheet = false + + var body: some View { + Button("Show Share Sheet") { isPresentingShareSheet = true } + .shareSheet(isPresented: $isPresentingShareSheet, items: ["Share me!"]) + } +} + +struct ShareSheetTest_Previews: PreviewProvider { + static var previews: some View { + ShareSheetTest() + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift new file mode 100644 index 0000000000..74015c2def --- /dev/null +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -0,0 +1,97 @@ +// +// TerminalView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct TerminalView: View { + @EnvironmentObject var chatModel: ChatModel + @State var inProgress: Bool = false + @FocusState private var keyboardVisible: Bool + + var body: some View { + VStack { + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + ForEach(chatModel.terminalItems) { item in + NavigationLink { + ScrollView { + Text(item.details) + .textSelection(.enabled) + .padding() + } + } label: { + HStack { + Text(item.id.formatted(date: .omitted, time: .standard)) + Text(item.label) + .frame(maxWidth: .infinity, maxHeight: 30, alignment: .leading) + } + .padding(.horizontal) + } + } + .onAppear { scrollToBottom(proxy) } + .onChange(of: chatModel.terminalItems.count) { _ in scrollToBottom(proxy) } + .onChange(of: keyboardVisible) { _ in + if keyboardVisible { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + scrollToBottom(proxy, animation: .easeInOut(duration: 1)) + } + } + } + } + } + + Spacer() + + SendMessageView( + sendMessage: sendMessage, + inProgress: inProgress, + keyboardVisible: $keyboardVisible + ) + } + } + .navigationViewStyle(.stack) + .navigationTitle("Chat console") + } + + func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { + if let id = chatModel.terminalItems.last?.id { + withAnimation(animation) { + proxy.scrollTo(id, anchor: .bottom) + } + } + } + + func sendMessage(_ cmdStr: String) { + let cmd = ChatCommand.string(cmdStr) + DispatchQueue.global().async { + inProgress = true + do { + let _ = try chatSendCmd(cmd) + } catch { + logger.error("TerminalView.sendMessage chatSendCmd error: \(error.localizedDescription)") + } + inProgress = false + } + } +} + +struct TerminalView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.terminalItems = [ + .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), + .resp(.now, ChatResponse.response(type: "newChatItem", json: "{}")) + ] + return NavigationView { + TerminalView() + .environmentObject(chatModel) + } + + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift new file mode 100644 index 0000000000..7bfc2eec49 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift @@ -0,0 +1,36 @@ +// +// SettingsButton.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SettingsButton: View { + @EnvironmentObject var chatModel: ChatModel + @State private var showSettings = false + + var body: some View { + Button { showSettings = true } label: { + Image(systemName: "gearshape") + } + .sheet(isPresented: $showSettings, content: { + SettingsView(showSettings: $showSettings) + .onAppear { + do { + chatModel.userAddress = try apiGetUserAddress() + } catch { + logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)") + } + } + }) + } +} + +struct SettingsButton_Previews: PreviewProvider { + static var previews: some View { + SettingsButton() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift new file mode 100644 index 0000000000..35d969e1f0 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -0,0 +1,124 @@ +// +// SettingsView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")! + +struct SettingsView: View { + @EnvironmentObject var chatModel: ChatModel + @Binding var showSettings: Bool + + var body: some View { + let user: User = chatModel.currentUser! + + return NavigationView { + List { + Section("You") { + NavigationLink { + UserProfile() + .navigationTitle("Your chat profile") + } label: { + HStack { + Image(systemName: "person.crop.circle") + .padding(.trailing, 8) + VStack(alignment: .leading) { + Text(user.profile.displayName) + .fontWeight(.bold) + .font(.title2) + Text(user.profile.fullName) + } + } + } + NavigationLink { + UserAddress() + .navigationTitle("Your chat address") + } label: { + HStack { + Image(systemName: "qrcode") + .padding(.trailing, 8) + Text("Your SimpleX contact address") + } + } + } + + Section("Help") { + NavigationLink { + VStack(alignment: .leading, spacing: 10) { + Text("Welcome \(user.displayName)!") + .font(.largeTitle) + .padding(.leading) + Divider() + ChatHelp(showSettings: $showSettings) + } + .frame(maxHeight: .infinity, alignment: .top) + } label: { + HStack { + Image(systemName: "questionmark.circle") + .padding(.trailing, 8) + Text("How to use SimpleX Chat") + } + } + HStack { + Image(systemName: "number") + .padding(.trailing, 8) + Button { + showSettings = false + DispatchQueue.main.async { + UIApplication.shared.open(simplexTeamURL) + } + } label: { + Text("Get help & advice via chat") + } + } + HStack { + Image(systemName: "envelope") + .padding(.trailing, 4) + Text("[Ask questions via email](mailto:chat@simplex.chat)") + } + } + + Section("Develop") { + NavigationLink { + TerminalView() + } label: { + HStack { + Image(systemName: "terminal") + .frame(maxWidth: 24) + .padding(.trailing, 8) + Text("Chat console") + } + } + HStack { + Image("github") + .resizable() + .frame(width: 24, height: 24) + .padding(.trailing, 8) + Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") + } + } + +// Section("Your SimpleX servers") { +// +// } + } + .navigationTitle("Your settings") + } + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = User.sampleData + @State var showSettings = false + + return SettingsView(showSettings: $showSettings) + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift new file mode 100644 index 0000000000..7d6c8cca65 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -0,0 +1,73 @@ +// +// UserAddress.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct UserAddress: View { + @EnvironmentObject var chatModel: ChatModel + @State private var shareAddressLink = false + @State private var deleteAddressAlert = false + + var body: some View { + VStack (alignment: .leading) { + Text("You can share your address as a link or as a QR code - anybody will be able to connect to you, and if you later delete it - you won't lose your contacts.") + .padding(.bottom) + if let userAdress = chatModel.userAddress { + QRCode(uri: userAdress) + HStack { + Button { shareAddressLink = true } label: { + Label("Share link", systemImage: "square.and.arrow.up") + } + .padding() + .shareSheet(isPresented: $shareAddressLink, items: [userAdress]) + + Button { deleteAddressAlert = true } label: { + Label("Delete address", systemImage: "trash") + } + .padding() + .alert(isPresented: $deleteAddressAlert) { + Alert( + title: Text("Delete address?"), + message: Text("All your contacts will remain connected"), + primaryButton: .destructive(Text("Delete")) { + do { + try apiDeleteUserAddress() + chatModel.userAddress = nil + } catch let error { + logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)") + } + }, secondaryButton: .cancel() + ) + } + .shareSheet(isPresented: $shareAddressLink, items: [userAdress]) + } + .frame(maxWidth: .infinity) + } else { + Button { + do { + chatModel.userAddress = try apiCreateUserAddress() + } catch let error { + logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)") + } + } label: { Label("Create address", systemImage: "qrcode") } + .frame(maxWidth: .infinity) + } + } + .padding() + .frame(maxHeight: .infinity, alignment: .top) + } +} + +struct UserAddress_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + return UserAddress() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift new file mode 100644 index 0000000000..2dd5575033 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -0,0 +1,84 @@ +// +// UserProfile.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct UserProfile: View { + @EnvironmentObject var chatModel: ChatModel + @State private var profile = Profile(displayName: "", fullName: "") + @State private var editProfile: Bool = false + + var body: some View { + let user: User = chatModel.currentUser! + + return VStack(alignment: .leading) { + Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.") + .padding(.bottom) + if editProfile { + VStack(alignment: .leading) { + TextField("Display name", text: $profile.displayName) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.bottom) + TextField("Full name (optional)", text: $profile.fullName) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.bottom) + HStack(spacing: 20) { + Button("Cancel") { editProfile = false } + Button("Save (and notify contacts)") { saveProfile() } + } + } + .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) + } else { + VStack(alignment: .leading) { + HStack { + Text("Display name:") + Text(user.profile.displayName) + .fontWeight(.bold) + } + .padding(.bottom) + HStack { + Text("Full name:") + Text(user.profile.fullName) + .fontWeight(.bold) + } + .padding(.bottom) + Button("Edit") { + profile = user.profile + editProfile = true + } + } + .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) + } + } + .padding() + .frame(maxHeight: .infinity, alignment: .top) + } + + func saveProfile() { + do { + if let newProfile = try apiUpdateProfile(profile: profile) { + chatModel.currentUser?.profile = newProfile + profile = newProfile + } + } catch { + logger.error("UserProfile apiUpdateProfile error: \(error.localizedDescription)") + } + editProfile = false + } +} + +struct UserProfile_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = User.sampleData + return UserProfile() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift new file mode 100644 index 0000000000..02ab3d1653 --- /dev/null +++ b/apps/ios/Shared/Views/WelcomeView.swift @@ -0,0 +1,65 @@ +// +// WelcomeView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 18/01/2022. +// + +import SwiftUI + +struct WelcomeView: View { + @EnvironmentObject var chatModel: ChatModel + @State var displayName: String = "" + @State var fullName: String = "" + + var body: some View { + GeometryReader { g in + VStack(alignment: .leading) { + Image("logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: g.size.width * 0.7) + .padding(.vertical) + Text("You control your chat!") + .font(.title) + .padding(.bottom) + Text("The messaging and application platform protecting your privacy and security.") + .padding(.bottom, 8) + Text("We don't store any of your contacts or messages (once delivered) on the servers.") + .padding(.bottom, 24) + Text("Create profile") + .font(.largeTitle) + .padding(.bottom) + Text("Your profile is stored on your device and shared only with your contacts.") + .padding(.bottom) + TextField("Display name", text: $displayName) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.bottom) + TextField("Full name (optional)", text: $fullName) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.bottom) + Button("Create") { + let profile = Profile( + displayName: displayName, + fullName: fullName + ) + do { + let user = try apiCreateActiveUser(profile) + chatModel.currentUser = user + } catch { + fatalError("Failed to create user: \(error)") + } + } + } + } + .padding() + } +} + +struct WelcomeView_Previews: PreviewProvider { + static var previews: some View { + WelcomeView() + } +} diff --git a/apps/ios/Shared/dummy.m b/apps/ios/Shared/dummy.m new file mode 100644 index 0000000000..73cb36a91d --- /dev/null +++ b/apps/ios/Shared/dummy.m @@ -0,0 +1,8 @@ +// +// dummy.m +// SimpleX +// +// Created by Evgeny Poberezkin on 22/01/2022. +// + +#import diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements new file mode 100644 index 0000000000..3a6f1244b0 --- /dev/null +++ b/apps/ios/SimpleX (iOS).entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.associated-domains + + applinks:simplex.chat + applinks:www.simplex.chat + applinks:simplex.chat?mode=developer + + + diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist new file mode 100644 index 0000000000..b8279472cb --- /dev/null +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -0,0 +1,27 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + chat.simplex.app.receive + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + chat.simplex.app + CFBundleURLSchemes + + simplex + + + + UIBackgroundModes + + fetch + + + diff --git a/apps/ios/SimpleX--macOS--Info.plist b/apps/ios/SimpleX--macOS--Info.plist new file mode 100644 index 0000000000..0c67376eba --- /dev/null +++ b/apps/ios/SimpleX--macOS--Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..752c52f814 --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -0,0 +1,1081 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; + 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; + 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; + 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; + 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; + 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; + 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; + 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; + 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; + 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; + 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; + 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; + 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; + 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; + 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; + 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; + 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; + 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; + 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; + 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; + 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; + 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; + 5C75059C27B5CD9300BE3227 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059727B5CD9300BE3227 /* libgmp.a */; }; + 5C75059D27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */; }; + 5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059927B5CD9300BE3227 /* libffi.a */; }; + 5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */; }; + 5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059B27B5CD9300BE3227 /* libgmpxx.a */; }; + 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; + 5C764E81279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; + 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; + 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; + 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; + 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; + 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; + 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; + 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; + 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; + 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; + 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; + 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; + 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; + 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; + 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; + 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; + 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; + 5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */; }; + 5CA059E8279559F40002BEB4 /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */; }; + 5CA059EA279559F40002BEB4 /* Tests_macOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */; }; + 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; + 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; + 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; + 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; + 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; + 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; + 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; + 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; + 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; + 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; + 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; + 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; + 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; + 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; + 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; + 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; + 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; + 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; + 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; + 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; + 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; + 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; + 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; + 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; + 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; + 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; + 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; + 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; + 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; + 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; + 5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; }; + 5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; }; + 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; + 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CA059C9279559F40002BEB4; + remoteInfo = "SimpleX (iOS)"; + }; + 5CA059E4279559F40002BEB4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CA059CF279559F40002BEB4; + remoteInfo = "SimpleX (macOS)"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; + 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; + 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; + 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; + 5C2E260927A2C63500F70299 /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; + 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; + 5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; }; + 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; + 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; + 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; + 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; + 5C75059727B5CD9300BE3227 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a"; sourceTree = ""; }; + 5C75059927B5CD9300BE3227 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a"; sourceTree = ""; }; + 5C75059B27B5CD9300BE3227 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; + 5C764E7C279C71DB000C6508 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; + 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (iOS)-Bridging-Header.h"; sourceTree = ""; }; + 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = ""; }; + 5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; + 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; + 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; + 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; + 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; + 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; + 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = ""; }; + 5CA059C4279559F40002BEB4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 5CA059C5279559F40002BEB4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 5CA059CA279559F40002BEB4 /* SimpleX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleX.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CA059D0279559F40002BEB4 /* SimpleX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleX.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; + 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; + 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; }; + 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOSLaunchTests.swift; sourceTree = ""; }; + 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; + 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; + 5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = ""; }; + 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = ""; }; + 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; + 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; + 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; + 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = ""; }; + 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; + 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; + 5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = ""; }; + 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5CA059C7279559F40002BEB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, + 5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */, + 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, + 5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */, + 5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */, + 5C75059C27B5CD9300BE3227 /* libgmp.a in Frameworks */, + 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, + 5C75059D27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059CD279559F40002BEB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, + 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059D4279559F40002BEB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059E0279559F40002BEB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5C2E260D27A30E2400F70299 /* Views */ = { + isa = PBXGroup; + children = ( + 5C971E1F27AEBF7000C8A3CE /* Helpers */, + 5C5F4AC227A5E9AF00B51EF1 /* Chat */, + 5CB9250B27A942F300ACCCDD /* ChatList */, + 5CB924DD27A8622200ACCCDD /* NewChat */, + 5CB924DF27A8678B00ACCCDD /* UserSettings */, + 5C2E261127A30FEA00F70299 /* TerminalView.swift */, + 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 5C5F4AC227A5E9AF00B51EF1 /* Chat */ = { + isa = PBXGroup; + children = ( + 5CE4407427ADB657007B033A /* ChatItem */, + 5C2E260E27A30FDC00F70299 /* ChatView.swift */, + 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, + 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, + 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, + 5CE4407127ADB1D0007B033A /* Emoji.swift */, + ); + path = Chat; + sourceTree = ""; + }; + 5C764E5C279C70B7000C6508 /* Libraries */ = { + isa = PBXGroup; + children = ( + 5C75059927B5CD9300BE3227 /* libffi.a */, + 5C75059727B5CD9300BE3227 /* libgmp.a */, + 5C75059B27B5CD9300BE3227 /* libgmpxx.a */, + 5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */, + 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */, + ); + path = Libraries; + sourceTree = ""; + }; + 5C764E7A279C71D4000C6508 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5C764E7C279C71DB000C6508 /* libz.tbd */, + 5C764E7B279C71D4000C6508 /* libiconv.tbd */, + ); + name = Frameworks; + sourceTree = ""; + }; + 5C764E87279CBC8E000C6508 /* Model */ = { + isa = PBXGroup; + children = ( + 5C764E88279CBCB3000C6508 /* ChatModel.swift */, + 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, + 5C9FD96A27A56D4D0075386C /* JSON.swift */, + 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, + 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, + ); + path = Model; + sourceTree = ""; + }; + 5C971E1F27AEBF7000C8A3CE /* Helpers */ = { + isa = PBXGroup; + children = ( + 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 5CA059BD279559F40002BEB4 = { + isa = PBXGroup; + children = ( + 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, + 5C764E5C279C70B7000C6508 /* Libraries */, + 5CA059C2279559F40002BEB4 /* Shared */, + 5CA059D1279559F40002BEB4 /* macOS */, + 5CA059DA279559F40002BEB4 /* Tests iOS */, + 5CA059E6279559F40002BEB4 /* Tests macOS */, + 5CA059CB279559F40002BEB4 /* Products */, + 5C764E7A279C71D4000C6508 /* Frameworks */, + ); + sourceTree = ""; + }; + 5CA059C2279559F40002BEB4 /* Shared */ = { + isa = PBXGroup; + children = ( + 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, + 5CA059C4279559F40002BEB4 /* ContentView.swift */, + 5C764E87279CBC8E000C6508 /* Model */, + 5C2E260D27A30E2400F70299 /* Views */, + 5CA059C5279559F40002BEB4 /* Assets.xcassets */, + 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, + 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */, + 5C764E7F279C7276000C6508 /* dummy.m */, + 5C2E260927A2C63500F70299 /* MyPlayground.playground */, + ); + path = Shared; + sourceTree = ""; + }; + 5CA059CB279559F40002BEB4 /* Products */ = { + isa = PBXGroup; + children = ( + 5CA059CA279559F40002BEB4 /* SimpleX.app */, + 5CA059D0279559F40002BEB4 /* SimpleX.app */, + 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 5CA059D1279559F40002BEB4 /* macOS */ = { + isa = PBXGroup; + children = ( + ); + path = macOS; + sourceTree = ""; + }; + 5CA059DA279559F40002BEB4 /* Tests iOS */ = { + isa = PBXGroup; + children = ( + 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */, + 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */, + ); + path = "Tests iOS"; + sourceTree = ""; + }; + 5CA059E6279559F40002BEB4 /* Tests macOS */ = { + isa = PBXGroup; + children = ( + 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */, + 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */, + ); + path = "Tests macOS"; + sourceTree = ""; + }; + 5CB924DD27A8622200ACCCDD /* NewChat */ = { + isa = PBXGroup; + children = ( + 5C6AD81227A834E300348BD7 /* NewChatButton.swift */, + 5CCD403327A5F6DF00368C90 /* AddContactView.swift */, + 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */, + 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, + 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, + 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, + ); + path = NewChat; + sourceTree = ""; + }; + 5CB924DF27A8678B00ACCCDD /* UserSettings */ = { + isa = PBXGroup; + children = ( + 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, + 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, + 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, + 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, + ); + path = UserSettings; + sourceTree = ""; + }; + 5CB9250B27A942F300ACCCDD /* ChatList */ = { + isa = PBXGroup; + children = ( + 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, + 5C5346A727B59A6A004DF848 /* ChatHelp.swift */, + 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, + 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, + 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, + ); + path = ChatList; + sourceTree = ""; + }; + 5CE4407427ADB657007B033A /* ChatItem */ = { + isa = PBXGroup; + children = ( + 5CE4407527ADB66A007B033A /* TextItemView.swift */, + 5CE4407827ADB701007B033A /* EmojiItemView.swift */, + ); + path = ChatItem; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CA059F3279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (iOS)" */; + buildPhases = ( + 5CA059C6279559F40002BEB4 /* Sources */, + 5CA059C7279559F40002BEB4 /* Frameworks */, + 5CA059C8279559F40002BEB4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "SimpleX (iOS)"; + packageProductDependencies = ( + 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */, + ); + productName = "SimpleX (iOS)"; + productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; + productType = "com.apple.product-type.application"; + }; + 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CA059F6279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (macOS)" */; + buildPhases = ( + 5CA059CC279559F40002BEB4 /* Sources */, + 5CA059CD279559F40002BEB4 /* Frameworks */, + 5CA059CE279559F40002BEB4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "SimpleX (macOS)"; + productName = "SimpleX (macOS)"; + productReference = 5CA059D0279559F40002BEB4 /* SimpleX.app */; + productType = "com.apple.product-type.application"; + }; + 5CA059D6279559F40002BEB4 /* Tests iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CA059F9279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests iOS" */; + buildPhases = ( + 5CA059D3279559F40002BEB4 /* Sources */, + 5CA059D4279559F40002BEB4 /* Frameworks */, + 5CA059D5279559F40002BEB4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5CA059D9279559F40002BEB4 /* PBXTargetDependency */, + ); + name = "Tests iOS"; + productName = "Tests iOS"; + productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 5CA059E2279559F40002BEB4 /* Tests macOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CA059FC279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests macOS" */; + buildPhases = ( + 5CA059DF279559F40002BEB4 /* Sources */, + 5CA059E0279559F40002BEB4 /* Frameworks */, + 5CA059E1279559F40002BEB4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5CA059E5279559F40002BEB4 /* PBXTargetDependency */, + ); + name = "Tests macOS"; + productName = "Tests macOS"; + productReference = 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5CA059BE279559F40002BEB4 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1320; + LastUpgradeCheck = 1320; + ORGANIZATIONNAME = "SimpleX Chat"; + TargetAttributes = { + 5CA059C9279559F40002BEB4 = { + CreatedOnToolsVersion = 13.2.1; + LastSwiftMigration = 1320; + }; + 5CA059CF279559F40002BEB4 = { + CreatedOnToolsVersion = 13.2.1; + LastSwiftMigration = 1320; + }; + 5CA059D6279559F40002BEB4 = { + CreatedOnToolsVersion = 13.2.1; + TestTargetID = 5CA059C9279559F40002BEB4; + }; + 5CA059E2279559F40002BEB4 = { + CreatedOnToolsVersion = 13.2.1; + TestTargetID = 5CA059CF279559F40002BEB4; + }; + }; + }; + buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 5CA059BD279559F40002BEB4; + packageReferences = ( + 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */, + ); + productRefGroup = 5CA059CB279559F40002BEB4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, + 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */, + 5CA059D6279559F40002BEB4 /* Tests iOS */, + 5CA059E2279559F40002BEB4 /* Tests macOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5CA059C8279559F40002BEB4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059CE279559F40002BEB4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059D5279559F40002BEB4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059E1279559F40002BEB4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5CA059C6279559F40002BEB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, + 5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */, + 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, + 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 5C764E80279C7276000C6508 /* dummy.m in Sources */, + 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, + 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, + 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, + 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, + 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, + 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, + 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */, + 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, + 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, + 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, + 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, + 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, + 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, + 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */, + 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, + 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, + 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, + 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, + 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, + 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, + 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, + 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059CC279559F40002BEB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */, + 5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */, + 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */, + 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */, + 5C764E81279C7276000C6508 /* dummy.m in Sources */, + 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, + 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, + 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, + 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, + 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, + 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */, + 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */, + 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, + 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, + 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */, + 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, + 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, + 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, + 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */, + 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, + 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, + 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, + 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, + 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, + 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */, + 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */, + 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059D3279559F40002BEB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */, + 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059DF279559F40002BEB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CA059EA279559F40002BEB4 /* Tests_macOSLaunchTests.swift in Sources */, + 5CA059E8279559F40002BEB4 /* Tests_macOS.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 5CA059D9279559F40002BEB4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; + targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; + }; + 5CA059E5279559F40002BEB4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */; + targetProxy = 5CA059E4279559F40002BEB4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 5CA059F1279559F40002BEB4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5CA059F2279559F40002BEB4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 5CA059F4279559F40002BEB4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; + INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ""; + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; + MARKETING_VERSION = 0.3; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; + PRODUCT_NAME = SimpleX; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (iOS)-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5CA059F5279559F40002BEB4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; + INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ""; + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; + MARKETING_VERSION = 0.3; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; + PRODUCT_NAME = SimpleX; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (iOS)-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5CA059F7279559F40002BEB4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX (macOS)Debug.entitlements"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX--macOS--Info.plist"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries", + "$(PROJECT_DIR)/Libraries/ios", + "$(PROJECT_DIR)/Libraries/sim", + ); + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; + PRODUCT_NAME = SimpleX; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (macOS)-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 5CA059F8279559F40002BEB4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX--macOS--Info.plist"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries", + "$(PROJECT_DIR)/Libraries/ios", + "$(PROJECT_DIR)/Libraries/sim", + ); + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; + PRODUCT_NAME = SimpleX; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (macOS)-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 5CA059FA279559F40002BEB4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "SimpleX (iOS)"; + }; + name = Debug; + }; + 5CA059FB279559F40002BEB4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "SimpleX (iOS)"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5CA059FD279559F40002BEB4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-macOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = "SimpleX (macOS)"; + }; + name = Debug; + }; + 5CA059FE279559F40002BEB4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-macOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = "SimpleX (macOS)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CA059F1279559F40002BEB4 /* Debug */, + 5CA059F2279559F40002BEB4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5CA059F3279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (iOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CA059F4279559F40002BEB4 /* Debug */, + 5CA059F5279559F40002BEB4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5CA059F6279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (macOS)" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CA059F7279559F40002BEB4 /* Debug */, + 5CA059F8279559F40002BEB4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5CA059F9279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CA059FA279559F40002BEB4 /* Debug */, + 5CA059FB279559F40002BEB4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5CA059FC279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests macOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CA059FD279559F40002BEB4 /* Debug */, + 5CA059FE279559F40002BEB4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twostraws/CodeScanner"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */ = { + isa = XCSwiftPackageProductDependency; + package = 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */; + productName = CodeScanner; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 5CA059BE279559F40002BEB4 /* Project object */; +} diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..9d4b1de373 --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "CodeScanner", + "repositoryURL": "https://github.com/twostraws/CodeScanner", + "state": { + "branch": null, + "revision": "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", + "version": "2.1.1" + } + } + ] + }, + "version": 1 +} diff --git a/apps/ios/Tests iOS/Tests_iOS.swift b/apps/ios/Tests iOS/Tests_iOS.swift new file mode 100644 index 0000000000..eeecf4d4fc --- /dev/null +++ b/apps/ios/Tests iOS/Tests_iOS.swift @@ -0,0 +1,42 @@ +// +// Tests_iOS.swift +// Tests iOS +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import XCTest + +class Tests_iOS: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use recording to get started writing UI tests. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/apps/ios/Tests iOS/Tests_iOSLaunchTests.swift b/apps/ios/Tests iOS/Tests_iOSLaunchTests.swift new file mode 100644 index 0000000000..d869e7357f --- /dev/null +++ b/apps/ios/Tests iOS/Tests_iOSLaunchTests.swift @@ -0,0 +1,32 @@ +// +// Tests_iOSLaunchTests.swift +// Tests iOS +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import XCTest + +class Tests_iOSLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/apps/ios/Tests macOS/Tests_macOS.swift b/apps/ios/Tests macOS/Tests_macOS.swift new file mode 100644 index 0000000000..ee05450dc0 --- /dev/null +++ b/apps/ios/Tests macOS/Tests_macOS.swift @@ -0,0 +1,42 @@ +// +// Tests_macOS.swift +// Tests macOS +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import XCTest + +class Tests_macOS: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use recording to get started writing UI tests. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift b/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift new file mode 100644 index 0000000000..84d51dadbd --- /dev/null +++ b/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift @@ -0,0 +1,32 @@ +// +// Tests_macOSLaunchTests.swift +// Tests macOS +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import XCTest + +class Tests_macOSLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/apps/ios/macOS/macOS.entitlements b/apps/ios/macOS/macOS.entitlements new file mode 100644 index 0000000000..f2ef3ae026 --- /dev/null +++ b/apps/ios/macOS/macOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 55fe640014..cb223f6e0a 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -8,6 +8,7 @@ module Main where import Simplex.Chat import Simplex.Chat.Controller (versionNumber) import Simplex.Chat.Options +import Simplex.Chat.Terminal import System.Directory (getAppUserDataDirectory) import System.Terminal (withTerminal) @@ -20,8 +21,8 @@ main = do welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {dbFile} <- getChatOpts appDir + opts@ChatOpts {dbFilePrefix} <- getChatOpts appDir putStrLn $ "SimpleX Chat v" ++ versionNumber - putStrLn $ "db: " <> dbFile <> "_chat.db, " <> dbFile <> "_agent.db" + putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" putStrLn "type \"/help\" or \"/h\" for usage info" pure opts diff --git a/cabal.project b/cabal.project index 8422063751..1727e1efd6 100644 --- a/cabal.project +++ b/cabal.project @@ -2,11 +2,20 @@ packages: . source-repository-package type: git - location: git://github.com/simplex-chat/hs-tls.git - tag: cea6d52c512716ff09adcac86ebc95bb0b3bb797 - subdir: core + location: git://github.com/simplex-chat/simplexmq.git + tag: c380c795600b887fcae1614a52fb5cda691b569d + +source-repository-package + type: git + location: git://github.com/simplex-chat/aeson.git + tag: 3eb66f9a68f103b5f1489382aad89f5712a64db7 source-repository-package type: git location: git://github.com/simplex-chat/haskell-terminal.git - tag: 5e0759ce4f9655fd3f0d94c76225e6904630dfd3 + tag: f708b00009b54890172068f168bf98508ffcd495 + +source-repository-package + type: git + location: git://github.com/zw3rk/android-support.git + tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb diff --git a/direct-sqlite-2.3.26.patch b/direct-sqlite-2.3.26.patch new file mode 100644 index 0000000000..9ac2196ddd --- /dev/null +++ b/direct-sqlite-2.3.26.patch @@ -0,0 +1,15 @@ +diff --git a/direct-sqlite.cabal b/direct-sqlite.cabal +index 96f26b7..996198e 100644 +--- a/direct-sqlite.cabal ++++ b/direct-sqlite.cabal +@@ -69,7 +69,9 @@ library + install-includes: sqlite3.h, sqlite3ext.h + include-dirs: cbits + +- if !os(windows) && !os(android) ++ extra-libraries: dl ++ ++ if !os(windows) && !os(android) + extra-libraries: pthread + + if flag(fulltextsearch) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..8ab1093a0c --- /dev/null +++ b/flake.lock @@ -0,0 +1,341 @@ +{ + "nodes": { + "HTTP": { + "flake": false, + "locked": { + "lastModified": 1451647621, + "narHash": "sha256-oHIyw3x0iKBexEo49YeUDV1k74ZtyYKGR2gNJXXRxts=", + "owner": "phadej", + "repo": "HTTP", + "rev": "9bc0996d412fef1787449d841277ef663ad9a915", + "type": "github" + }, + "original": { + "owner": "phadej", + "repo": "HTTP", + "type": "github" + } + }, + "cabal-32": { + "flake": false, + "locked": { + "lastModified": 1603716527, + "narHash": "sha256-sDbrmur9Zfp4mPKohCD8IDZfXJ0Tjxpmr2R+kg5PpSY=", + "owner": "haskell", + "repo": "cabal", + "rev": "94aaa8e4720081f9c75497e2735b90f6a819b08e", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.2", + "repo": "cabal", + "type": "github" + } + }, + "cabal-34": { + "flake": false, + "locked": { + "lastModified": 1622475795, + "narHash": "sha256-chwTL304Cav+7p38d9mcb+egABWmxo2Aq+xgVBgEb/U=", + "owner": "haskell", + "repo": "cabal", + "rev": "b086c1995cdd616fc8d91f46a21e905cc50a1049", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.4", + "repo": "cabal", + "type": "github" + } + }, + "cabal-36": { + "flake": false, + "locked": { + "lastModified": 1640163203, + "narHash": "sha256-TwDWP2CffT0j40W6zr0J1Qbu+oh3nsF1lUx9446qxZM=", + "owner": "haskell", + "repo": "cabal", + "rev": "ecf418050c1821f25e2e218f1be94c31e0465df1", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.6", + "repo": "cabal", + "type": "github" + } + }, + "cardano-shell": { + "flake": false, + "locked": { + "lastModified": 1608537748, + "narHash": "sha256-PulY1GfiMgKVnBci3ex4ptk2UNYMXqGjJOxcPy2KYT4=", + "owner": "input-output-hk", + "repo": "cardano-shell", + "rev": "9392c75087cb9a3d453998f4230930dea3a95725", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "cardano-shell", + "type": "github" + } + }, + "flake-utils": { + "locked": { + "lastModified": 1638122382, + "narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "74f7e4319258e287b0f9cb95426c9853b282730b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "locked": { + "lastModified": 1623875721, + "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "ghc-8.6.5-iohk": { + "flake": false, + "locked": { + "lastModified": 1600920045, + "narHash": "sha256-DO6kxJz248djebZLpSzTGD6s8WRpNI9BTwUeOf5RwY8=", + "owner": "input-output-hk", + "repo": "ghc", + "rev": "95713a6ecce4551240da7c96b6176f980af75cae", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "release/8.6.5-iohk", + "repo": "ghc", + "type": "github" + } + }, + "hackage": { + "flake": false, + "locked": { + "lastModified": 1642986764, + "narHash": "sha256-U6FPiNjz9JctwKC838LEoT/xjGfb8L18ZGIEY5YYzdU=", + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "22406c79a506164c4e835a68e54739f63f918784", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "hackage.nix", + "type": "github" + } + }, + "haskellNix": { + "inputs": { + "HTTP": "HTTP", + "cabal-32": "cabal-32", + "cabal-34": "cabal-34", + "cabal-36": "cabal-36", + "cardano-shell": "cardano-shell", + "flake-utils": "flake-utils_2", + "ghc-8.6.5-iohk": "ghc-8.6.5-iohk", + "hackage": "hackage", + "hpc-coveralls": "hpc-coveralls", + "nix-tools": "nix-tools", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-2003": "nixpkgs-2003", + "nixpkgs-2105": "nixpkgs-2105", + "nixpkgs-2111": "nixpkgs-2111", + "nixpkgs-unstable": "nixpkgs-unstable", + "old-ghc-nix": "old-ghc-nix", + "stackage": "stackage" + }, + "locked": { + "lastModified": 1643019329, + "narHash": "sha256-So77czYvvD0jt4GJeypkqw3VNn20ype5tHnHri2s5lg=", + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ddc654e2e7e44617bfc17a5aed2a0947d3e192cc", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "angerman/android-static", + "repo": "haskell.nix", + "type": "github" + } + }, + "hpc-coveralls": { + "flake": false, + "locked": { + "lastModified": 1607498076, + "narHash": "sha256-8uqsEtivphgZWYeUo5RDUhp6bO9j2vaaProQxHBltQk=", + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "rev": "14df0f7d229f4cd2e79f8eabb1a740097fdfa430", + "type": "github" + }, + "original": { + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "type": "github" + } + }, + "nix-tools": { + "flake": false, + "locked": { + "lastModified": 1636018067, + "narHash": "sha256-ng306fkuwr6V/malWtt3979iAC4yMVDDH2ViwYB6sQE=", + "owner": "input-output-hk", + "repo": "nix-tools", + "rev": "ed5bd7215292deba55d6ab7a4e8c21f8b1564dda", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "nix-tools", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1641457028, + "narHash": "sha256-bA31xSpdSIo+rJMbHPurlxIsP/b6bbN+jvXOqyn2lR8=", + "owner": "angerman", + "repo": "nixpkgs", + "rev": "7b049e87e9b371f9ea6648aa8f1f2d17b2e31ae5", + "type": "github" + }, + "original": { + "owner": "angerman", + "ref": "patch-1", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-2003": { + "locked": { + "lastModified": 1620055814, + "narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1db42b7fe3878f3f5f7a4f2dc210772fd080e205", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-20.03-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-2105": { + "locked": { + "lastModified": 1640283157, + "narHash": "sha256-6Ddfop+rKE+Gl9Tjp9YIrkfoYPzb8F80ergdjcq3/MY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "dde1557825c5644c869c5efc7448dc03722a8f09", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-2111": { + "locked": { + "lastModified": 1640283207, + "narHash": "sha256-SCwl7ZnCfMDsuSYvwIroiAlk7n33bW8HFfY8NvKhcPA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c7e3388bbd9206e437713351e814366e0c3284", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1641285291, + "narHash": "sha256-KYaOBNGar3XWTxTsYPr9P6u74KAqNq0wobEC236U+0c=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0432195a4b8d68faaa7d3d4b355260a3120aeeae", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "old-ghc-nix": { + "flake": false, + "locked": { + "lastModified": 1631092763, + "narHash": "sha256-sIKgO+z7tj4lw3u6oBZxqIhDrzSkvpHtv0Kki+lh9Fg=", + "owner": "angerman", + "repo": "old-ghc-nix", + "rev": "af48a7a7353e418119b6dfe3cd1463a657f342b8", + "type": "github" + }, + "original": { + "owner": "angerman", + "ref": "master", + "repo": "old-ghc-nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "haskellNix": "haskellNix", + "nixpkgs": "nixpkgs" + } + }, + "stackage": { + "flake": false, + "locked": { + "lastModified": 1642986888, + "narHash": "sha256-oxG7LzlJdjKTJgSv7diKWsGTETDZMPT2mNNLbrBfiVs=", + "owner": "input-output-hk", + "repo": "stackage.nix", + "rev": "aeaf5fe21874f01702f394d01e18f472be6e3e08", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "stackage.nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..de81fb10d8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,165 @@ +{ + description = "nix flake for simplex-chat"; + inputs.nixpkgs.url = "github:angerman/nixpkgs/patch-1"; # based on 21.11, still need this, until everything is merged into 21.11. + inputs.haskellNix.url = "github:input-output-hk/haskell.nix?ref=angerman/android-static"; + inputs.haskellNix.inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + outputs = { self, haskellNix, nixpkgs, flake-utils }: + let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in + flake-utils.lib.eachSystem systems (system: + let pkgs = haskellNix.legacyPackages.${system}; in + let drv = pkgs': pkgs'.haskell-nix.project { + compiler-nix-name = "ghc8107"; + index-state = "2022-01-24T00:00:00Z"; + # We need this, to specify we want the cabal project. + # If the stack.yaml was dropped, this would not be necessary. + projectFileName = "cabal.project"; + src = pkgs.haskell-nix.haskellLib.cleanGit { + name = "simplex-chat"; + src = ./.; + }; + sha256map = import ./sha256map.nix; + modules = [{ + packages.direct-sqlite.patches = [ ./direct-sqlite-2.3.26.patch ]; + } + ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { + packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; + })]; + }; in + # This will package up all *.a in $out into a pkg.zip that can + # be downloaded from hydra. + let withHydraLibPkg = pkg: pkg.overrideAttrs (old: { + postInstall = '' + mkdir -p $out/_pkg + find $out/lib -name "*.a" -exec cp {} $out/_pkg \; + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }); in + rec { + packages = { + "lib:simplex-chat" = (drv pkgs).simplex-chat.components.library; + "exe:simplex-chat" = (drv pkgs).simplex-chat.components.exes.simplex-chat; + } // ({ + "x86_64-linux" = + let + androidPkgs = pkgs.pkgsCross.aarch64-android; + # For some reason building libiconv with nixpgks android setup produces + # LANGINFO_CODESET to be found, which is not compatible with android sdk 23; + # so we'll patch up iconv to not include that. + androidIconv = (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { + postConfigure = '' + echo "#undef HAVE_LANGINFO_CODESET" >> libcharset/config.h + echo "#undef HAVE_LANGINFO_CODESET" >> lib/config.h + ''; + }); + # Similarly to icovn, for reasons beyond my current knowledge, nixpkgs andorid + # toolchain makes configure believe we have MEMFD_CREATE, which we don't in + # sdk 23. + androidFFI = androidPkgs.libffi.overrideAttrs (old: { + dontDisableStatic = true; + hardeningDisable = [ "fortify" ]; + postConfigure = '' + echo "#undef HAVE_MEMFD_CREATE" >> aarch64-unknown-linux-android/fficonfig.h + ''; + } + );in { + "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override { + smallAddressSpace = true; enableShared = false; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; + postInstall = '' + + mkdir -p $out/_pkg + cp libsupport.so $out/_pkg + ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *) + rm -fR $out/_pkg + + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + "aarch64-android:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; + # for android we build a shared library, passing these arguments is a bit tricky, as + # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for + # template haskell cross compilation. Thus we just pass them as linker options (-optl). + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"]; + postInstall = '' + ${pkgs.tree}/bin/tree $out + mkdir -p $out/_pkg + # copy over includes, we might want those, but maybe not. + # cp -r $out/lib/*/*/include $out/_pkg/ + # find the libHS...ghc-X.Y.Z.a static library; this is the + # rolled up one with all dependencies included. + cp libsimplex.so $out/_pkg + # find ./dist -name "lib*.so" -exec cp {} $out/_pkg \; + # find ./dist -name "libHS*-ghc*.a" -exec cp {} $out/_pkg \; + # find ${androidFFI}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidPkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidPkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \; + + ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsimplex.so + + ${pkgs.tree}/bin/tree $out/_pkg + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + }; + "aarch64-darwin" = { + "aarch64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; + # we need threaded here, otherwise all the queing logic doesn't work properly. + # for iOS we also use -staticlib, to get one rolled up library. + # still needs mac2ios patching of the archives. + ghcOptions = [ "-staticlib" "-threaded" ]; + postInstall = '' + ${pkgs.tree}/bin/tree $out + mkdir -p $out/_pkg + # copy over includes, we might want those, but maybe not. + # cp -r $out/lib/*/*/include $out/_pkg/ + # find the libHS...ghc-X.Y.Z.a static library; this is the + # rolled up one with all dependencies included. + find ./dist -name "libHS*.a" -exec cp {} $out/_pkg \; + find ${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib -name "*.a" -exec cp {} $out/_pkg \; + find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; + # There is no static libc + ${pkgs.tree}/bin/tree $out/_pkg + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + }; + }.${system} or {}); + # build all packages in hydra. + hydraJobs = packages; + + devShell = let + updateCmd = pkgs.writeShellApplication { + name = "update-sha256map"; + runtimeInputs = [ pkgs.nix-prefetch-git pkgs.jq pkgs.gawk ]; + text = '' + gawk -f update-sha256.awk cabal.project > sha256map.nix + ''; + }; in + pkgs.mkShell { + buildInputs = [ updateCmd ]; + shellHook = '' + echo "welcome to the shell!" + ''; + }; + } + ); +} diff --git a/package.yaml b/package.yaml index d8bfde04b2..d3f458ebd0 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.0.1 +version: 1.1.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme @@ -10,12 +10,12 @@ copyright: 2020-22 simplex.chat category: Web, System, Services, Cryptography extra-source-files: - README.md - - migrations/*.* dependencies: - - aeson == 1.5.* + - aeson == 2.0.* - ansi-terminal >= 0.10 && < 0.12 - - attoparsec == 0.13.* + - async == 2.2.* + - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 - bytestring == 0.10.* @@ -24,7 +24,6 @@ dependencies: - cryptonite >= 0.27 && < 0.30 - directory == 1.3.* - exceptions == 0.10.* - - file-embed >= 0.0.14 && < 0.0.16 - filepath == 1.4.* - mtl == 2.2.* - optparse-applicative >= 0.15 && < 0.17 @@ -38,7 +37,6 @@ dependencies: - time == 1.9.* - unliftio == 0.2.* - unliftio-core == 0.2.* - - unordered-containers == 0.2.* library: source-dirs: src diff --git a/rfcs/2022-01-26-mobile-app.md b/rfcs/2022-01-26-mobile-app.md new file mode 100644 index 0000000000..56c2eb55c8 --- /dev/null +++ b/rfcs/2022-01-26-mobile-app.md @@ -0,0 +1,167 @@ +# Porting SimpleX Chat to mobile + +## Background and motivation + +We have code that "works", the aim is to keep platform differences in the core minimal and get the apps to market faster. + +### SimpleX platform design + +See [overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for overall platform design and objectives, it is worth reading the introduction. The diagram copied from this doc: + +``` + User's Computer Internet Third-Party Server +------------------ | ---------------------- | ------------------------- + | | + SimpleX Chat | | + | | ++----------------+ | | +| Chat App | | | ++----------------+ | | +| SimpleX Agent | | | ++----------------+ -------------- TLS ---------------- +----------------+ +| SimpleX Client | ------ SimpleX Messaging Protocol ------> | SimpleX Server | ++----------------+ ----------------------------------- +----------------+ + | | +``` + +- SimpleX Servers only pass messages, we don't need to touch that for the app +- SimpleX clients talk to the servers, we won't use them directly +- SimpleX agent is used from chat, we won't use it directly from the app +- Chat app will expose API to the app to communicate with everything, including DB and network. + +### Important application modules + +Modules of simplexmq package used from simplex-chat: + - a [functional API in Agent.hs]([Agent.hs](https://github.com/simplex-chat/simplexmq/blob/master/src/Simplex/Messaging/Agent.hs#L38)) to send messages and commands + - TBQueue to receive messages and notifications (specifically, [subQ field of AgentClient record in Agent/Client.hs](https://github.com/simplex-chat/simplexmq/blob/master/src/Simplex/Messaging/Agent/Client.hs#L72)) + - [types from Agent/Protocol.hs](https://github.com/simplex-chat/simplexmq/blob/master/src/Simplex/Messaging/Agent/Protocol.hs)). + +This package has its [own sqlite database file](https://github.com/simplex-chat/simplexmq/tree/master/migrations) - as v1 was not backwards compatible migrations are restarted - where it stores all encryption and signing keys, shared secrets, servers and queue addresses - effectively it completely abstracts the network away from chat application, providing an API to manage logical duplex connections. + +Simplex-chat library is what we will use from the app: + - command type [ChatCommand in Chat.hs](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat.hs#L72) that UI can send to it + - UI sends these commands via TBQueue that `inputSubscriber` reads in forever loop and sends to `processChatCommand`. There is a hack that `inputSubscriber` not only reads commands but also shows them in the view, depending on the commands. + - collection of [view functions in Chat/View.hs](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/View.hs) to reflect all events in view. + +This package also creates its own [database file](https://github.com/simplex-chat/simplex-chat/tree/master/migrations) where it stores references to agent connections managed by the agent, and how they map to contacts, groups, and file transmissions. + +## App design options and questions + +### Sending chat commands from UI and receiving them in Haskell + +Possible options: +- function (exported via FFI) that receives strings from UI and decodes them into ChatCommand type, then sending this command to `processChatCommand`. This option requires a single function in C header file, but also requires encoding in UI and decoding in Haskell. +- multiple functions exported via FFI each sending different command to `processChatCommand`. This option requires multiple functions in header file and multiple exports from Haskell. + +Overall, the second option seems a bit simpler and cleaner, if we agree to go this route we will refactor `processChatCommand` to expose its parts that process different commands as independent functions. + +On another hand, it might be easier to grow chat API if this is passed via a single function and serialized as strings (e.g. as JSON, to have it more universal) - it would also might give us an API for a possible future chat server that works with thin, UI-only clients. + +In both cases, we should split `processChatCommand` (or the functions it calls) into a separate module, so it does not have code that is not used from the app. + +**Proposal** + +Use option 2 to send commands from UI to chat, encoding/decoding commands as strings with a tag in the beginning (TBC binary, text or JSON based - encoding will have to be replicated in UI land; both encoding and decoding is needed in Haskell land to refactor terminal chat to use this layer as well, so we have a standard API for all implementations). + +This function would have this type: + +```haskell +sendRequest :: CString -> IO CString +``` + +to allow instant responses. + +One more idea. This function could be made to match REST semantics that would simplify making chat into a REST chat server api: + +```haskell +sendRequest :: CString -> CString -> CString -> CString -> IO CString +sendRequest verb path qs body = pure "" +``` + +### Sending messages and notifications from Haskell to UI + +Firstly, we have to refactor the existing code so that all functions in [View.hs](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/View.hs) are passed to `processChatCommand` (or the functions for each command, if we go with this approach) as a single record containing all view functions. + +The current code from View.hs will not be used in the mobile app, it is terminal specific; we will create a separate connector to the UI that has the same functions in a record - these functions communicate to the UI. + +Again, there are two similar options how this communication can happen: +- UIs would export multiple functions however each platform allows it, as C exports, and they would be all imported in Haskell. This option feels definitely worse, as it would have to be maintained in both iOS and Android separately for exports, and in Haskell for imports, resulting in lots of boilerplate. +- UIs would export one function that receives strings (e.g. JSON encoded) with the messages and notifications, there will be one function in Haskell to send these JSON. All required view functions in Haskell land would simply send different strings into the same function. + +In this case the second option seems definitely easier, as even with simple terminal UI there are more view events than chat commands (although, given different mobile UI paradigms some of these events may not be needed, but some additional events are likely to be addedd, that would be doing nothing for terminal app). + +**Proposal** + +Encode messages and notifications as JSON, but instead of exporting the function from UI (which would have to be done differently from different platforms), have Haskell export function `receiveMessage` that would be blocking until the next notification or message is available. UI would handle it in a simple loop, on a separate thread: + +```haskell +-- CString is serialized JSON (ToJSON serialized datatype from haskell) +receiveMessage :: IO CString () +``` + +To convert between Haskell and C interface: + +```haskell +type CJSON = CString + +toCJSON ToJSON a => a -> CJSON +toCJSON = ... + +-- Haskell interface +send :: ToJSON a => String -> IO a +recv :: ToJSON a => IO a + +-- C interface +c_send :: CString -> IO CJSON +c_recv :: IO CJSON +``` + +### Accessing chat database from the UI + +Unlike terminal UI that does not provide any capabilities to access chat history, mobile UI needs to have access to it. + +Two options how it can be done: +- UI accesses database directly via its own database library. The upside of this approach is that it keeps Haskel core smaller. The downside is that sqlite is relatively bad with concurrent access. In Haskell code we allowed some concurrency initially, having the pool limited to few concurrent connection, but later we removed concurrency (by limiting pool size to 1), as otherwise it required retrying to get transaction locks with difficult to set retry time limits, and leading to deadlocks in some cases. Also mobile sqlite seems to be compiled with concurrency disabled, so we would have to ship app with our own sqlite (which we might have to do anyway, for the sake of full text search support). We could use some shared semaphore in Haskell to obtain database lock, but it adds extra complexity... +- UI accesses database via Haskell functions. The upside of this is that there would be no issues with concurrency, and chat schema would be "owned" by Haskell core, but it requires either a separate serializable protocol for database access or multiple exported functions (same two options as before). + +However bad the second option is, it seems slightly better as at least we would not have to duplicate sql quiries in iOS and Android. But this is the trade-off I am least certain of... + +**Proposal** + +Use the same `sendRequest` function to access database. + +Additional idea: as these calls should never mutate chat database, they should only query the state, and as these functions will not be needed for terminal UI, I think we could export it as a separate function and have all necessary queries/functions in a separate module, e.g.: + +```haskell +-- params and result are JSON encoded +chatQuery :: CString -> IO CString +chatQuery params = pure "" +``` + +On another hand, if we go with REST-like `sendRequest` then it definitely should be the only function to access chat and database state. + +### UI database + +UI needs to have its own storage to store information about user settings in the app and, possibly, which chat profiles the user has (each would have its own chat/agent databases). + +### Chat database initialization + +Currently it is done in an ad hoc way, during the application start ([`getCreateActiveUser` function](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat.hs#L1178)), we could either expose this function to accept database name or just check on the start and initialize database with the default name in case it is not present. + +### Multiple profiles in the app + +All user profiles are stored in the same database. The current schema allows multiple profiles, but the current UI does not. We do not need to do it in the app MVP. + +## Notifications + +We don't need it in the first version - it is out of scope of releasable MVP - but we need to think a bit ahead how it will be done so it doesn't invalidate the design we settle on. + +There is no reliable background execution, so the only way to receive messages when the app is off is via notifications. We have added notification subscriptions to the low protocol layer so that Haskell core would receive function call when notification arrives to the native part and receive and process messages and communicate back to the local part that would show a local notification on the device: + +``` +Push notification -> Native -> Haskell ... process ... -> Native -> Local notification +``` + +Notifications are the main reason why we will need to store multiple profiles in the same database file - when notification arrives we do not know which profile it is for, it only has server address and queue ID, and if different profiles were in different databases we would either had to have a single table mapping queues to profiles or lookup multiple databases - both options seem worse than a single database with multiple profiles. + +For the rest we would just use the same approaches we would use for UI/Haskell communications - probably a separate functions to receive notifications to Haskell, and the same events to be sent back. diff --git a/sha256map.nix b/sha256map.nix new file mode 100644 index 0000000000..3d0a905a3e --- /dev/null +++ b/sha256map.nix @@ -0,0 +1,6 @@ +{ + "git://github.com/simplex-chat/simplexmq.git"."c380c795600b887fcae1614a52fb5cda691b569d" = "0632zslrv8agvqrzzclb85jm4vdp8hwkvanh65jcd8j28nqsxlzh"; + "git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; + "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; + "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; +} diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f63210aed3..72c24eea3f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.0.1 +version: 1.1.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -16,21 +16,26 @@ license-file: LICENSE build-type: Simple extra-source-files: README.md - migrations/20220101_initial.sql library exposed-modules: Simplex.Chat Simplex.Chat.Controller Simplex.Chat.Help - Simplex.Chat.Input Simplex.Chat.Markdown - Simplex.Chat.Notification + Simplex.Chat.Messages + Simplex.Chat.Migrations.M20220101_initial + Simplex.Chat.Migrations.M20220122_v1_1 + Simplex.Chat.Migrations.M20220205_chat_item_status + Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol Simplex.Chat.Store Simplex.Chat.Styled Simplex.Chat.Terminal + Simplex.Chat.Terminal.Input + Simplex.Chat.Terminal.Notification + Simplex.Chat.Terminal.Output Simplex.Chat.Types Simplex.Chat.Util Simplex.Chat.View @@ -40,9 +45,10 @@ library src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns build-depends: - aeson ==1.5.* + aeson ==2.0.* , ansi-terminal >=0.10 && <0.12 - , attoparsec ==0.13.* + , async ==2.2.* + , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , bytestring ==0.10.* @@ -51,7 +57,6 @@ library , cryptonite >=0.27 && <0.30 , directory ==1.3.* , exceptions ==0.10.* - , file-embed >=0.0.14 && <0.0.16 , filepath ==1.4.* , mtl ==2.2.* , optparse-applicative >=0.15 && <0.17 @@ -65,7 +70,6 @@ library , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* - , unordered-containers ==0.2.* default-language: Haskell2010 executable simplex-chat @@ -76,9 +80,10 @@ executable simplex-chat apps/simplex-chat ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==1.5.* + aeson ==2.0.* , ansi-terminal >=0.10 && <0.12 - , attoparsec ==0.13.* + , async ==2.2.* + , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , bytestring ==0.10.* @@ -87,7 +92,6 @@ executable simplex-chat , cryptonite >=0.27 && <0.30 , directory ==1.3.* , exceptions ==0.10.* - , file-embed >=0.0.14 && <0.0.16 , filepath ==1.4.* , mtl ==2.2.* , optparse-applicative >=0.15 && <0.17 @@ -102,7 +106,6 @@ executable simplex-chat , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* - , unordered-containers ==0.2.* default-language: Haskell2010 test-suite simplex-chat-test @@ -112,16 +115,17 @@ test-suite simplex-chat-test ChatClient ChatTests MarkdownTests + MobileTests ProtocolTests Paths_simplex_chat hs-source-dirs: tests ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns build-depends: - aeson ==1.5.* + aeson ==2.0.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* - , attoparsec ==0.13.* + , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , bytestring ==0.10.* @@ -130,7 +134,6 @@ test-suite simplex-chat-test , cryptonite >=0.27 && <0.30 , directory ==1.3.* , exceptions ==0.10.* - , file-embed >=0.0.14 && <0.0.16 , filepath ==1.4.* , hspec ==2.7.* , mtl ==2.2.* @@ -147,5 +150,4 @@ test-suite simplex-chat-test , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* - , unordered-containers ==0.2.* default-language: Haskell2010 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4e4d1e41bc..ff7932c709 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -6,7 +6,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} -{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} @@ -34,20 +33,16 @@ import qualified Data.Map.Strict as M import Data.Maybe (isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Time.LocalTime (getCurrentTimeZone) import Data.Word (Word32) import Simplex.Chat.Controller -import Simplex.Chat.Help -import Simplex.Chat.Input -import Simplex.Chat.Notification +import Simplex.Chat.Messages import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Protocol import Simplex.Chat.Store -import Simplex.Chat.Styled (plain) -import Simplex.Chat.Terminal import Simplex.Chat.Types -import Simplex.Chat.Util (ifM, unlessM, whenM) -import Simplex.Chat.View +import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM, whenM) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), defaultAgentConfig) import Simplex.Messaging.Agent.Protocol @@ -55,59 +50,20 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) import qualified Simplex.Messaging.Protocol as SMP -import Simplex.Messaging.Util (raceAny_, tryError) +import Simplex.Messaging.Util (tryError) import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) import Text.Read (readMaybe) -import UnliftIO.Async (race_) +import UnliftIO.Async (Async, async, race_) import UnliftIO.Concurrent (forkIO, threadDelay) import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory) import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell) import UnliftIO.STM -data ChatCommand - = ChatHelp - | FilesHelp - | GroupsHelp - | MyAddressHelp - | MarkdownHelp - | Welcome - | AddContact - | Connect (Maybe AConnectionRequestUri) - | ConnectAdmin - | DeleteContact ContactName - | ListContacts - | CreateMyAddress - | DeleteMyAddress - | ShowMyAddress - | AcceptContact ContactName - | RejectContact ContactName - | SendMessage ContactName ByteString - | NewGroup GroupProfile - | AddMember GroupName ContactName GroupMemberRole - | JoinGroup GroupName - | RemoveMember GroupName ContactName - | MemberRole GroupName ContactName GroupMemberRole - | LeaveGroup GroupName - | DeleteGroup GroupName - | ListMembers GroupName - | ListGroups - | SendGroupMessage GroupName ByteString - | SendFile ContactName FilePath - | SendGroupFile GroupName FilePath - | ReceiveFile Int64 (Maybe FilePath) - | CancelFile Int64 - | FileStatus Int64 - | UpdateProfile Profile - | ShowProfile - | QuitChat - | ShowVersion - deriving (Show) - defaultChatConfig :: ChatConfig defaultChatConfig = ChatConfig @@ -116,151 +72,185 @@ defaultChatConfig = { tcpPort = undefined, -- agent does not listen to TCP smpServers = undefined, -- filled in from options dbFile = undefined, -- filled in from options - dbPoolSize = 1 + dbPoolSize = 1, + yesToMigrations = False }, dbPoolSize = 1, + yesToMigrations = False, tbqSize = 16, - fileChunkSize = 15780 + fileChunkSize = 15780, + testView = False } logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChat cfg opts t = - -- setLogLevel LogInfo -- LogError - -- withGlobalLogging logCfg $ do - initializeNotifications - >>= newChatController cfg opts t - >>= runSimplexChat - -newChatController :: WithTerminal t => ChatConfig -> ChatOpts -> t -> (Notification -> IO ()) -> IO ChatController -newChatController config@ChatConfig {agentConfig = cfg, dbPoolSize, tbqSize} ChatOpts {dbFile, smpServers} t sendNotification = do - let f = chatStoreFile dbFile +newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController +newChatController chatStore user config@ChatConfig {agentConfig = cfg, tbqSize} ChatOpts {dbFilePrefix, smpServers} sendNotification = do + let f = chatStoreFile dbFilePrefix + activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f - chatStore <- createStore f dbPoolSize - currentUser <- newTVarIO =<< getCreateActiveUser chatStore - chatTerminal <- newChatTerminal t - smpAgent <- getSMPAgentClient cfg {dbFile = dbFile <> "_agent.db", smpServers} + currentUser <- newTVarIO user + smpAgent <- getSMPAgentClient cfg {dbFile = dbFilePrefix <> "_agent.db", smpServers} + agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize + outputQ <- newTBQueueIO tbqSize notifyQ <- newTBQueueIO tbqSize chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty - pure ChatController {..} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} -runSimplexChat :: ChatController -> IO () -runSimplexChat = runReaderT $ do - user <- readTVarIO =<< asks currentUser - whenM (asks firstTime) . printToView $ chatWelcome user - race_ runTerminalInput runChatController +runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +runChatController = race_ notificationSubscriber . agentSubscriber -runChatController :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -runChatController = - raceAny_ - [ inputSubscriber, - agentSubscriber, - notificationSubscriber - ] +startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m (Async ()) +startChatController user = do + s <- asks agentAsync + readTVarIO s >>= maybe (start s) pure + where + start s = do + a <- async $ runChatController user + atomically . writeTVar s $ Just a + pure a -withLock :: MonadUnliftIO m => TMVar () -> m () -> m () +withLock :: MonadUnliftIO m => TMVar () -> m a -> m a withLock lock = E.bracket_ (void . atomically $ takeTMVar lock) (atomically $ putTMVar lock ()) -inputSubscriber :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -inputSubscriber = do - q <- asks inputQ - l <- asks chatLock - a <- asks smpAgent - forever $ - atomically (readTBQueue q) >>= \case - InputControl _ -> pure () - InputCommand s -> - case parseAll chatCommandP . B.dropWhileEnd isSpace . encodeUtf8 $ T.pack s of - Left e -> printToView [plain s, "invalid input: " <> plain e] - Right cmd -> do - case cmd of - SendMessage c msg -> showSentMessage c msg - SendGroupMessage g msg -> showSentGroupMessage g msg - SendFile c f -> showSentFileInvitation c f - SendGroupFile g f -> showSentGroupFileInvitation g f - _ -> printToView [plain s] - user <- readTVarIO =<< asks currentUser - withAgentLock a . withLock l . void . runExceptT $ - processChatCommand user cmd `catchError` showChatError +execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse +execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of + Left e -> pure $ chatCmdError e + Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd) -processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m () -processChatCommand user@User {userId, profile} = \case - ChatHelp -> printToView chatHelpInfo - FilesHelp -> printToView filesHelpInfo - GroupsHelp -> printToView groupsHelpInfo - MyAddressHelp -> printToView myAddressHelpInfo - MarkdownHelp -> printToView markdownInfo - Welcome -> printToView $ chatWelcome user - AddContact -> do +toView :: ChatMonad m => ChatResponse -> m () +toView event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, event) + +processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse +processChatCommand = \case + ShowActiveUser -> withUser' $ pure . CRActiveUser + CreateActiveUser p -> do + u <- asks currentUser + whenM (isJust <$> readTVarIO u) $ throwChatError CEActiveUserExists + user <- withStore $ \st -> createUser st p True + atomically . writeTVar u $ Just user + pure $ CRActiveUser user + StartChat -> withUser' $ \user -> startChatController user $> CRChatStarted + APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user)) + APIGetChat cType cId pagination -> withUser $ \user -> case cType of + CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) + CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) + CTContactRequest -> pure $ chatCmdError "not implemented" + APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" + APISendMessage cType chatId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + CTDirect -> do + ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId + ci <- sendDirectChatItem userId ct (XMsgNew mc) (CISndMsgContent mc) + setActive $ ActiveC c + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + CTGroup -> do + group@(Group gInfo@GroupInfo {localDisplayName = gName, membership} _) <- withStore $ \st -> getGroup st user chatId + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) + setActive $ ActiveG gName + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + CTContactRequest -> pure $ chatCmdError "not supported" + APIChatRead cType chatId fromToIds -> withChatLock $ case cType of + CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk + CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk + CTContactRequest -> pure $ chatCmdError "not supported" + APIDeleteChat cType chatId -> withUser $ \User {userId} -> case cType of + CTDirect -> do + ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId + withStore (\st -> getContactGroupNames st userId ct) >>= \case + [] -> do + conns <- withStore $ \st -> getContactConnections st userId ct + withChatLock . procCmd $ do + withAgent $ \a -> forM_ conns $ \conn -> + deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () + withStore $ \st -> deleteContact st userId ct + unsetActive $ ActiveC localDisplayName + pure $ CRContactDeleted ct + gs -> throwChatError $ CEContactGroups ct gs + CTGroup -> pure $ chatCmdError "not implemented" + CTContactRequest -> pure $ chatCmdError "not supported" + APIAcceptContact connReqId -> withUser $ \User {userId, profile} -> do + UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p} <- withStore $ \st -> + getContactRequest st userId connReqId + withChatLock . procCmd $ do + connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile + acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p + pure $ CRAcceptingContactRequest acceptedContact + APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do + cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- + withStore $ \st -> + getContactRequest st userId connReqId + `E.finally` deleteContactRequest st userId connReqId + withAgent $ \a -> rejectContact a connId invId + pure $ CRContactRequestRejected cReq + ChatHelp section -> pure $ CRChatHelp section + Welcome -> withUser $ pure . CRWelcome + AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) withStore $ \st -> createDirectConnection st userId connId - showInvitation cReq - Connect (Just (ACR SCMInvitation cReq)) -> connect cReq (XInfo profile) >> showSentConfirmation - Connect (Just (ACR SCMContact cReq)) -> connect cReq (XContact profile Nothing) >> showSentInvitation - Connect Nothing -> showInvalidConnReq - ConnectAdmin -> connect adminContactReq (XContact profile Nothing) >> showSentInvitation - DeleteContact cName -> - withStore (\st -> getContactGroupNames st userId cName) >>= \case - [] -> do - conns <- withStore $ \st -> getContactConnections st userId cName - withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> - deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () - withStore $ \st -> deleteContact st userId cName - unsetActive $ ActiveC cName - showContactDeleted cName - gs -> showContactGroups cName gs - ListContacts -> withStore (`getUserContacts` user) >>= showContactsList - CreateMyAddress -> do + pure $ CRInvitation cReq + Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do + connect userId cReq $ XInfo profile + pure CRSentConfirmation + Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do + connect userId cReq $ XContact profile Nothing + pure CRSentInvitation + Connect Nothing -> throwChatError CEInvalidConnReq + ConnectAdmin -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do + connect userId adminContactReq $ XContact profile Nothing + pure CRSentInvitation + DeleteContact cName -> withUser $ \User {userId} -> do + contactId <- withStore $ \st -> getContactIdByName st userId cName + processChatCommand $ APIDeleteChat CTDirect contactId + ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user) + CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) withStore $ \st -> createUserContactLink st userId connId cReq - showUserContactLinkCreated cReq - DeleteMyAddress -> do + pure $ CRUserContactLinkCreated cReq + DeleteMyAddress -> withUser $ \User {userId} -> withChatLock $ do conns <- withStore $ \st -> getUserContactLinkConnections st userId - withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> - deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () - withStore $ \st -> deleteUserContactLink st userId - showUserContactLinkDeleted - ShowMyAddress -> do - cReq <- withStore $ \st -> getUserContactLink st userId - showUserContactLink cReq - AcceptContact cName -> do - UserContactRequest {agentInvitationId, profileId} <- withStore $ \st -> - getContactRequest st userId cName - connId <- withAgent $ \a -> acceptContact a agentInvitationId . directMessage $ XInfo profile - withStore $ \st -> createAcceptedContact st userId connId cName profileId - showAcceptingContactRequest cName - RejectContact cName -> do - UserContactRequest {agentContactConnId, agentInvitationId} <- withStore $ \st -> - getContactRequest st userId cName - `E.finally` deleteContactRequest st userId cName - withAgent $ \a -> rejectContact a agentContactConnId agentInvitationId - showContactRequestRejected cName - SendMessage cName msg -> sendMessageCmd cName msg - NewGroup gProfile -> do + procCmd $ do + withAgent $ \a -> forM_ conns $ \conn -> + deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () + withStore $ \st -> deleteUserContactLink st userId + pure CRUserContactLinkDeleted + ShowMyAddress -> CRUserContactLink <$> withUser (\User {userId} -> withStore (`getUserContactLink` userId)) + AcceptContact cName -> withUser $ \User {userId} -> do + connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName + processChatCommand $ APIAcceptContact connReqId + RejectContact cName -> withUser $ \User {userId} -> do + connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName + processChatCommand $ APIRejectContact connReqId + SendMessage cName msg -> withUser $ \User {userId} -> do + contactId <- withStore $ \st -> getContactIdByName st userId cName + let mc = MCText $ safeDecodeUtf8 msg + processChatCommand $ APISendMessage CTDirect contactId mc + NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg - group <- withStore $ \st -> createNewGroup st gVar user gProfile - showGroupCreated group - AddMember gName cName memRole -> do - (group, contact) <- withStore $ \st -> (,) <$> getGroup st user gName <*> getContact st userId cName - let Group {groupId, groupProfile, membership, members} = group + CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) + AddMember gName cName 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 $ \st -> (,) <$> getGroupByName st user gName <*> getContactByName st userId cName + let Group gInfo@GroupInfo {groupId, groupProfile, membership} members = group GroupMember {memberRole = userRole, memberId = userMemberId} = membership - when (userRole < GRAdmin || userRole < memRole) $ chatError CEGroupUserRole - when (memberStatus membership == GSMemInvited) $ chatError (CEGroupNotJoined gName) - unless (memberActive membership) $ chatError CEGroupMemberNotActive + when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole + when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo) + unless (memberActive membership) $ throwChatError CEGroupMemberNotActive let sendInvitation memberId cReq = do - sendDirectMessage (contactConn contact) $ + void . sendDirectMessage (contactConn contact) $ XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile - showSentGroupInvitation gName cName setActive $ ActiveG gName + pure $ CRSentGroupInvitation gInfo contact case contactMember contact members of Nothing -> do gVar <- asks idsDrg @@ -271,127 +261,151 @@ processChatCommand user@User {userId, profile} = \case | memberStatus == GSMemInvited -> withStore (\st -> getMemberInvitation st user groupMemberId) >>= \case Just cReq -> sendInvitation memberId cReq - Nothing -> showCannotResendInvitation gName cName - | otherwise -> chatError (CEGroupDuplicateMember cName) - JoinGroup gName -> do - ReceivedGroupInvitation {fromMember, userMember, connRequest} <- withStore $ \st -> getGroupInvitation st user gName - agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (userMember :: GroupMember) - withStore $ \st -> do - createMemberConnection st userId fromMember agentConnId - updateGroupMemberStatus st userId fromMember GSMemAccepted - updateGroupMemberStatus st userId userMember GSMemAccepted - MemberRole _gName _cName _mRole -> pure () - RemoveMember gName cName -> do - Group {membership, members} <- withStore $ \st -> getGroup st user gName + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName + | otherwise -> throwChatError $ CEGroupDuplicateMember cName + JoinGroup gName -> withUser $ \user@User {userId} -> do + ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \st -> getGroupInvitation st user gName + withChatLock . procCmd $ do + agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership g :: GroupMember) + withStore $ \st -> do + createMemberConnection st userId fromMember agentConnId + updateGroupMemberStatus st userId fromMember GSMemAccepted + updateGroupMemberStatus st userId (membership g) GSMemAccepted + pure $ CRUserAcceptedGroupSent g + MemberRole _gName _cName _mRole -> throwChatError $ CECommandError "unsupported" + RemoveMember gName cName -> withUser $ \user@User {userId} -> do + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName case find ((== cName) . (localDisplayName :: GroupMember -> ContactName)) members of - Nothing -> chatError $ CEGroupMemberNotFound cName + Nothing -> throwChatError $ CEGroupMemberNotFound cName Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus} -> do let userRole = memberRole (membership :: GroupMember) - when (userRole < GRAdmin || userRole < mRole) $ chatError CEGroupUserRole - when (mStatus /= GSMemInvited) . sendGroupMessage members $ XGrpMemDel mId - deleteMemberConnection m - withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved - showDeletedMember gName Nothing (Just m) - LeaveGroup gName -> do - Group {membership, members} <- withStore $ \st -> getGroup st user gName - sendGroupMessage members XGrpLeave - mapM_ deleteMemberConnection members - withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft - showLeftMemberUser gName - DeleteGroup gName -> do - g@Group {membership, members} <- withStore $ \st -> getGroup st user gName + when (userRole < GRAdmin || userRole < mRole) $ throwChatError CEGroupUserRole + withChatLock . procCmd $ do + when (mStatus /= GSMemInvited) . void . sendGroupMessage members $ XGrpMemDel mId + deleteMemberConnection m + withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved + pure $ CRUserDeletedMember gInfo m + LeaveGroup gName -> withUser $ \user@User {userId} -> do + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName + withChatLock . procCmd $ do + void $ sendGroupMessage members XGrpLeave + mapM_ deleteMemberConnection members + withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft + pure $ CRLeftMemberUser gInfo + DeleteGroup gName -> withUser $ \user -> do + g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroupByName st user gName let s = memberStatus membership canDelete = memberRole (membership :: GroupMember) == GROwner || (s == GSMemRemoved || s == GSMemLeft || s == GSMemGroupDeleted || s == GSMemInvited) - unless canDelete $ chatError CEGroupUserRole - when (memberActive membership) $ sendGroupMessage members XGrpDel - mapM_ deleteMemberConnection members - withStore $ \st -> deleteGroup st user g - showGroupDeletedUser gName - ListMembers gName -> do - group <- withStore $ \st -> getGroup st user gName - showGroupMembers group - ListGroups -> withStore (`getUserGroupDetails` userId) >>= showGroupsList - SendGroupMessage gName msg -> do - -- TODO save pending message delivery for members without connections - Group {members, membership} <- withStore $ \st -> getGroup st user gName - unless (memberActive membership) $ chatError CEGroupMemberUserRemoved - let msgEvent = XMsgNew . MCText $ safeDecodeUtf8 msg - sendGroupMessage members msgEvent - setActive $ ActiveG gName - SendFile cName f -> do + unless canDelete $ throwChatError CEGroupUserRole + withChatLock . procCmd $ do + when (memberActive membership) . void $ sendGroupMessage members XGrpDel + mapM_ deleteMemberConnection members + withStore $ \st -> deleteGroup st user g + pure $ CRGroupDeletedUser gInfo + ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName)) + ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user)) + SendGroupMessage gName msg -> withUser $ \user -> do + groupId <- withStore $ \st -> getGroupIdByName st user gName + let mc = MCText $ safeDecodeUtf8 msg + processChatCommand $ APISendMessage CTGroup groupId mc + SendFile cName f -> withUser $ \User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f - contact <- withStore $ \st -> getContact st userId cName + contact <- withStore $ \st -> getContactByName st userId cName (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq} SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize - sendDirectMessage (contactConn contact) $ XFile fileInv - showSentFileInfo fileId + ci <- sendDirectChatItem userId contact (XFile fileInv) (CISndFileInvitation fileId f) + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci setActive $ ActiveC cName - SendGroupFile gName f -> do + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci + SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f - group@Group {members, membership} <- withStore $ \st -> getGroup st user gName - unless (memberActive membership) $ chatError CEGroupMemberUserRemoved + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved let fileName = takeFileName f ms <- forM (filter memberActive members) $ \m -> do (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq}) - fileId <- withStore $ \st -> createSndGroupFileTransfer st userId group ms f fileSize chSize - -- TODO sendGroupMessage - same file invitation to all + fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize + -- TODO sendGroupChatItem - same file invitation to all forM_ ms $ \(m, _, fileInv) -> traverse (`sendDirectMessage` XFile fileInv) $ memberConn m - showSentFileInfo fileId setActive $ ActiveG gName - ReceiveFile fileId filePath_ -> do + -- this is a hack as we have multiple direct messages instead of one per group + let ciContent = CISndFileInvitation fileId f + createdAt <- liftIO getCurrentTime + let ci = mkNewChatItem ciContent 0 createdAt createdAt + ciMeta@CIMeta {itemId} <- saveChatItem userId (CDGroupSnd gInfo) ci + withStore $ \st -> updateFileTransferChatItemId st fileId itemId + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ ChatItem CIGroupSnd ciMeta ciContent + ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId - unless (fileStatus == RFSNew) . chatError $ CEFileAlreadyReceiving fileName - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case - Right agentConnId -> do - filePath <- getRcvFilePath fileId filePath_ fileName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - showRcvFileAccepted ft filePath - Left (ChatErrorAgent (SMP SMP.AUTH)) -> showRcvFileSndCancelled ft - Left (ChatErrorAgent (CONN DUPLICATE)) -> showRcvFileSndCancelled ft - Left e -> throwError e - CancelFile fileId -> - withStore (\st -> getFileTransfer st userId fileId) >>= \case + unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName + withChatLock . procCmd $ do + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case + Right agentConnId -> do + filePath <- getRcvFilePath fileId filePath_ fileName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + pure $ CRRcvFileAccepted ft filePath + Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft + Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft + Left e -> throwError e + CancelFile fileId -> withUser $ \User {userId} -> do + ft' <- withStore (\st -> getFileTransfer st userId fileId) + withChatLock . procCmd $ case ft' of FTSnd fts -> do forM_ fts $ \ft -> cancelSndFileTransfer ft - showSndGroupFileCancelled fts + pure $ CRSndGroupFileCancelled fts FTRcv ft -> do cancelRcvFileTransfer ft - showRcvFileCancelled ft + pure $ CRRcvFileCancelled ft FileStatus fileId -> - withStore (\st -> getFileTransferProgress st userId fileId) >>= showFileTransferStatus - UpdateProfile p -> unless (p == profile) $ do - user' <- withStore $ \st -> updateUserProfile st user p - asks currentUser >>= atomically . (`writeTVar` user') - contacts <- withStore (`getUserContacts` user) - forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p - showUserProfileUpdated user user' - ShowProfile -> showUserProfile profile + CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) + ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile + UpdateProfile p@Profile {displayName} -> withUser $ \user@User {profile} -> + if p == profile + then pure CRUserProfileNoChange + else do + withStore $ \st -> updateUserProfile st user p + let user' = (user :: User) {localDisplayName = displayName, profile = p} + asks currentUser >>= atomically . (`writeTVar` Just user') + contacts <- withStore (`getUserContacts` user) + withChatLock . procCmd $ do + forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p + pure $ CRUserProfileUpdated profile p QuitChat -> liftIO exitSuccess - ShowVersion -> printToView clientVersionInfo + ShowVersion -> pure CRVersionInfo where - connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () - connect cReq msg = do + withChatLock action = do + ChatController {chatLock = l, smpAgent = a} <- ask + withAgentLock a . withLock l $ action + -- below code would make command responses asynchronous where they can be slow + -- in View.hs `r'` should be defined as `id` in this case + procCmd :: m ChatResponse -> m ChatResponse + procCmd action = do + ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask + corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 + void . forkIO $ + withAgentLock a . withLock l $ + (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError)) + pure $ CRCmdAccepted corrId + -- use function below to make commands "synchronous" + -- procCmd :: m ChatResponse -> m ChatResponse + -- procCmd = id + connect :: UserId -> ConnectionRequestUri c -> ChatMsgEvent -> m () + connect userId cReq msg = do connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg withStore $ \st -> createDirectConnection st userId connId - sendMessageCmd :: ContactName -> ByteString -> m () - sendMessageCmd cName msg = do - contact <- withStore $ \st -> getContact st userId cName - let msgEvent = XMsgNew . MCText $ safeDecodeUtf8 msg - sendDirectMessage (contactConn contact) msgEvent - setActive $ ActiveC cName contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft checkSndFile :: FilePath -> m (Integer, Integer) checkSndFile f = do - unlessM (doesFileExist f) . chatError $ CEFileNotFound f + unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f (,) <$> getFileSize f <*> asks (fileChunkSize . config) getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath getRcvFilePath fileId filePath fileName = case filePath of @@ -406,11 +420,11 @@ processChatCommand user@User {userId, profile} = \case (fPath `uniqueCombine` fileName >>= createEmptyFile) $ ifM (doesFileExist fPath) - (chatError $ CEFileAlreadyExists fPath) + (throwChatError $ CEFileAlreadyExists fPath) (createEmptyFile fPath) where createEmptyFile :: FilePath -> m FilePath - createEmptyFile fPath = emptyFile fPath `E.catch` (chatError . CEFileWrite fPath) + createEmptyFile fPath = emptyFile fPath `E.catch` (throwChatError . CEFileWrite fPath . (show :: E.SomeException -> String)) emptyFile :: FilePath -> m FilePath emptyFile fPath = do h <- getFileHandle fileId fPath rcvFiles AppendMode @@ -425,52 +439,51 @@ processChatCommand user@User {userId, profile} = \case f = filePath `combine` (name <> suffix <> ext) in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) -agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -agentSubscriber = do +agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +agentSubscriber user = do q <- asks $ subQ . smpAgent l <- asks chatLock - subscribeUserConnections + subscribeUserConnections user forever $ do (_, connId, msg) <- atomically $ readTBQueue q - user <- readTVarIO =<< asks currentUser + u <- readTVarIO =<< asks currentUser withLock l . void . runExceptT $ - processAgentMessage user connId msg `catchError` showChatError + processAgentMessage u connId msg `catchError` (toView . CRChatError) -subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -subscribeUserConnections = void . runExceptT $ do - user <- readTVarIO =<< asks currentUser - subscribeContacts user - subscribeGroups user - subscribeFiles user - subscribePendingConnections user - subscribeUserContactLink user +subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +subscribeUserConnections user@User {userId} = void . runExceptT $ do + subscribeContacts + subscribeGroups + subscribeFiles + subscribePendingConnections + subscribeUserContactLink where - subscribeContacts user = do + subscribeContacts = do contacts <- withStore (`getUserContacts` user) - forM_ contacts $ \ct@Contact {localDisplayName = c} -> - (subscribe (contactConnId ct) >> showContactSubscribed c) `catchError` showContactSubError c - subscribeGroups user = do + forM_ contacts $ \ct -> + (subscribe (contactConnId ct) >> toView (CRContactSubscribed ct)) `catchError` (toView . CRContactSubError ct) + subscribeGroups = do groups <- withStore (`getUserGroups` user) - forM_ groups $ \g@Group {members, membership, localDisplayName = gn} -> do + forM_ groups $ \(Group g@GroupInfo {membership} members) -> do let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members if memberStatus membership == GSMemInvited - then showGroupInvitation g + then toView $ CRGroupInvitation g else if null connectedMembers then if memberActive membership - then showGroupEmpty g - else showGroupRemoved g + then toView $ CRGroupEmpty g + else toView $ CRGroupRemoved g else do forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) -> - subscribe cId `catchError` showMemberSubError gn c - showGroupSubscribed g - subscribeFiles user = do + subscribe cId `catchError` (toView . CRMemberSubError g c) + toView $ CRGroupSubscribed g + subscribeFiles = do withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile where - subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId} = do - subscribe agentConnId `catchError` showSndFileSubError ft + subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId cId} = do + subscribe cId `catchError` (toView . CRSndFileSubError ft) void . forkIO $ do threadDelay 1000000 l <- asks chatLock @@ -484,31 +497,28 @@ subscribeUserConnections = void . runExceptT $ do RFSConnected fInfo -> resume fInfo _ -> pure () where - resume RcvFileInfo {agentConnId} = - subscribe agentConnId `catchError` showRcvFileSubError ft - subscribePendingConnections user = do + resume RcvFileInfo {agentConnId = AgentConnId cId} = + subscribe cId `catchError` (toView . CRRcvFileSubError ft) + subscribePendingConnections = do cs <- withStore (`getPendingConnections` user) subscribeConns cs `catchError` \_ -> pure () - subscribeUserContactLink User {userId} = do + subscribeUserContactLink = do cs <- withStore (`getUserContactLinkConnections` userId) - (subscribeConns cs >> showUserContactLinkSubscribed) - `catchError` showUserContactLinkSubError + (subscribeConns cs >> toView CRUserContactLinkSubscribed) + `catchError` (toView . CRUserContactLinkSubError) subscribe cId = withAgent (`subscribeConnection` cId) subscribeConns conns = withAgent $ \a -> - forM_ conns $ \Connection {agentConnId} -> - subscribeConnection a agentConnId + forM_ conns $ subscribeConnection a . aConnId -processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agent -> m () -processAgentMessage user@User {userId, profile} agentConnId agentMessage = do - chatDirection <- withStore $ \st -> getConnectionChatDirection st user agentConnId - forM_ (agentMsgConnStatus agentMessage) $ \status -> - withStore $ \st -> updateConnectionStatus st (fromConnection chatDirection) status - case chatDirection of - ReceivedDirectMessage conn maybeContact -> - processDirectMessage agentMessage conn maybeContact - ReceivedGroupMessage conn gName m -> - processGroupMessage agentMessage conn gName m +processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m () +processAgentMessage Nothing _ _ = throwChatError CENoActiveUser +processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage = + (withStore (\st -> getConnectionEntity st user agentConnId) >>= updateConnStatus) >>= \case + RcvDirectMsgConnection conn contact_ -> + processDirectMessage agentMessage conn contact_ + RcvGroupMsgConnection conn gInfo m -> + processGroupMessage agentMessage conn gInfo m RcvFileConnection conn ft -> processRcvFileConn agentMessage conn ft SndFileConnection conn ft -> @@ -516,8 +526,16 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do UserContactConnection conn uc -> processUserContactRequest agentMessage conn uc where - isMember :: MemberId -> Group -> Bool - isMember memId Group {membership, members} = + updateConnStatus :: ConnectionEntity -> m ConnectionEntity + updateConnStatus acEntity = case agentMsgConnStatus agentMessage of + Just connStatus -> do + let conn = (entityConnection acEntity) {connStatus} + withStore $ \st -> updateConnectionStatus st conn connStatus + pure $ updateEntityConnStatus acEntity connStatus + Nothing -> pure acEntity + + isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool + isMember memId GroupInfo {membership} members = sameMemberId memId membership || isJust (find (sameMemberId memId) members) contactIsReady :: Contact -> Bool @@ -534,7 +552,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg conn = \case + processDirectMessage agentMsg conn@Connection {connId} = \case Nothing -> case agentMsg of CONF confId connInfo -> do saveConnInfo conn connInfo @@ -546,26 +564,27 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do withAckMessage agentConnId meta $ pure () ackMsgDeliveryEvent conn meta SENT msgId -> + -- ? updateDirectChatItem sentMsgDeliveryEvent conn msgId -- TODO print errors - MERR _ _ -> pure () + MERR _ _ -> pure () -- ? updateDirectChatItem ERR _ -> pure () -- TODO add debugging output _ -> pure () Just ct@Contact {localDisplayName = c} -> case agentMsg of - MSG meta msgBody -> do - chatMsgEvent <- saveRcvMSG conn meta msgBody - withAckMessage agentConnId meta $ + MSG msgMeta msgBody -> do + (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody + withAckMessage agentConnId msgMeta $ case chatMsgEvent of - XMsgNew (MCText text) -> newTextMessage c meta text - XFile fInv -> processFileInvitation ct meta fInv + XMsgNew mc -> newContentMessage ct mc msgId msgMeta + XFile fInv -> processFileInvitation ct fInv msgId msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe XInfoProbeCheck probeHash -> xInfoProbeCheck ct probeHash XInfoProbeOk probe -> xInfoProbeOk ct probe _ -> pure () - ackMsgDeliveryEvent conn meta + ackMsgDeliveryEvent conn msgMeta CONF confId connInfo -> do -- confirming direct connection with a member ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo @@ -590,34 +609,46 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do CON -> withStore (\st -> getViaGroupMember st user ct) >>= \case Nothing -> do - showContactConnected ct + toView $ CRContactConnected ct setActive $ ActiveC c showToast (c <> "> ") "connected" - Just (gName, m) -> + Just (gInfo, m) -> do when (memberIsReady m) $ do - notifyMemberConnected gName m + notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct - SENT msgId -> + SENT msgId -> do sentMsgDeliveryEvent conn msgId + chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId + case chatItemId_ of + Nothing -> pure () + Just chatItemId -> do + chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId CISSndSent + toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) END -> do - showContactAnotherClient c + toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c DOWN -> do - showContactDisconnected c + toView $ CRContactDisconnected ct showToast (c <> "> ") "disconnected" UP -> do - showContactSubscribed c + toView $ CRContactSubscribed ct showToast (c <> "> ") "is active" setActive $ ActiveC c -- TODO print errors - MERR _ _ -> pure () + MERR msgId err -> do + chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId + case chatItemId_ of + Nothing -> pure () + Just chatItemId -> do + chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId (agentErrToItemStatus err) + toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) ERR _ -> pure () -- TODO add debugging output _ -> pure () - processGroupMessage :: ACommand 'Agent -> Connection -> GroupName -> GroupMember -> m () - processGroupMessage agentMsg conn gName m = case agentMsg of + processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m () + processGroupMessage agentMsg conn gInfo@GroupInfo {localDisplayName = gName, membership} m = case agentMsg of CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case memberCategory m of @@ -634,7 +665,6 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do XGrpMemInfo memId _memProfile | sameMemberId memId m -> do -- TODO update member profile - Group {membership} <- withStore $ \st -> getGroup st user gName allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) profile | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" @@ -650,52 +680,52 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do _ -> messageError "INFO from member must have x.grp.mem.info" pure () CON -> do - group@Group {members, membership} <- withStore $ \st -> getGroup st user gName + members <- withStore $ \st -> getGroupMembers st user gInfo withStore $ \st -> do updateGroupMemberStatus st userId m GSMemConnected unless (memberActive membership) $ updateGroupMemberStatus st userId membership GSMemConnected - -- TODO forward any pending (GMIntroInvReceived) introductions + sendPendingGroupMessages m conn case memberCategory m of GCHostMember -> do - showUserJoinedGroup gName + toView $ CRUserJoinedGroup gInfo setActive $ ActiveG gName showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do - showJoinedGroupMember gName m + toView $ CRJoinedGroupMember gInfo m setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" - intros <- withStore $ \st -> createIntroductions st group m - sendGroupMessage members . XGrpMemNew $ memberInfo m - forM_ intros $ \intro -> do - sendDirectMessage conn . XGrpMemIntro . memberInfo $ reMember intro - withStore $ \st -> updateIntroStatus st intro GMIntroSent + intros <- withStore $ \st -> createIntroductions st members m + void . sendGroupMessage members . XGrpMemNew $ memberInfo m + forM_ intros $ \intro@GroupMemberIntro {introId} -> do + void . sendDirectMessage conn . XGrpMemIntro . memberInfo $ reMember intro + withStore $ \st -> updateIntroStatus st introId GMIntroSent _ -> do -- TODO send probe and decide whether to use existing contact connection or the new contact connection -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table withStore (\st -> getViaGroupContact st user m) >>= \case Nothing -> do - notifyMemberConnected gName m + notifyMemberConnected gInfo m messageError "implementation error: connected member does not have contact" Just ct -> when (contactIsReady ct) $ do - notifyMemberConnected gName m + notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct - MSG meta msgBody -> do - chatMsgEvent <- saveRcvMSG conn meta msgBody - withAckMessage agentConnId meta $ + MSG msgMeta msgBody -> do + (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody + withAckMessage agentConnId msgMeta $ case chatMsgEvent of - XMsgNew (MCText text) -> newGroupTextMessage gName m meta text - XFile fInv -> processGroupFileInvitation gName m meta fInv - XGrpMemNew memInfo -> xGrpMemNew gName m memInfo - XGrpMemIntro memInfo -> xGrpMemIntro conn gName m memInfo - XGrpMemInv memId introInv -> xGrpMemInv gName m memId introInv - XGrpMemFwd memInfo introInv -> xGrpMemFwd gName m memInfo introInv - XGrpMemDel memId -> xGrpMemDel gName m memId - XGrpLeave -> xGrpLeave gName m - XGrpDel -> xGrpDel gName m + XMsgNew mc -> newGroupContentMessage gInfo m mc msgId msgMeta + XFile fInv -> processGroupFileInvitation gInfo m fInv msgId msgMeta + XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo + XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo + XGrpMemInv memId introInv -> xGrpMemInv gInfo m memId introInv + XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m memInfo introInv + XGrpMemDel memId -> xGrpMemDel gInfo m memId + XGrpLeave -> xGrpLeave gInfo m + XGrpDel -> xGrpDel gInfo m _ -> messageError $ "unsupported message: " <> T.pack (show chatMsgEvent) - ackMsgDeliveryEvent conn meta + ackMsgDeliveryEvent conn msgMeta SENT msgId -> sentMsgDeliveryEvent conn msgId -- TODO print errors @@ -719,7 +749,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do withStore $ \st -> updateSndFileStatus st ft FSConnected - showSndFileStart ft + toView $ CRSndFileStart ft sendFileChunk ft SENT msgId -> do withStore $ \st -> updateSndFileChunkSent st ft msgId @@ -727,8 +757,8 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do MERR _ err -> do cancelSndFileTransfer ft case err of - SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ showSndFileRcvCancelled ft - _ -> chatError $ CEFileSend fileId err + SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ CRSndFileRcvCancelled ft + _ -> throwChatError $ CEFileSend fileId err MSG meta _ -> withAckMessage agentConnId meta $ pure () -- TODO print errors @@ -741,12 +771,12 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do case agentMsg of CON -> do withStore $ \st -> updateRcvFileStatus st ft FSConnected - showRcvFileStart ft + toView $ CRRcvFileStart ft MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case FileChunkCancel -> do cancelRcvFileTransfer ft - showRcvFileSndCancelled ft + toView $ CRRcvFileSndCancelled ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -766,7 +796,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do withStore $ \st -> do updateRcvFileStatus st ft FSComplete deleteRcvFileChunks st ft - showRcvFileComplete ft + toView $ CRRcvFileComplete ft closeFileHandle fileId rcvFiles withAgent (`deleteConnection` agentConnId) RcvChunkDuplicate -> pure () @@ -794,9 +824,9 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do where profileContactRequest :: InvitationId -> Profile -> m () profileContactRequest invId p = do - cName <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p - showReceivedContactRequest cName p - showToast (cName <> "> ") "wants to connect to you" + cReq@UserContactRequest {localDisplayName} <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p + toView $ CRReceivedContactRequest cReq + showToast (localDisplayName <> "> ") "wants to connect to you" withAckMessage :: ConnId -> MsgMeta -> m () -> m () withAckMessage cId MsgMeta {recipient = (msgId, _)} action = @@ -810,80 +840,103 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do sentMsgDeliveryEvent Connection {connId} msgId = withStore $ \st -> createSndMsgDeliveryEvent st connId msgId MDSSndSent + agentErrToItemStatus :: AgentErrorType -> CIStatus 'MDSnd + agentErrToItemStatus (SMP AUTH) = CISSndErrorAuth + agentErrToItemStatus err = CISSndError err + badRcvFileChunk :: RcvFileTransfer -> String -> m () badRcvFileChunk ft@RcvFileTransfer {fileStatus} err = case fileStatus of RFSCancelled _ -> pure () _ -> do cancelRcvFileTransfer ft - chatError $ CEFileRcvChunk err + throwChatError $ CEFileRcvChunk err - notifyMemberConnected :: GroupName -> GroupMember -> m () - notifyMemberConnected gName m@GroupMember {localDisplayName} = do - showConnectedToGroupMember gName m - setActive $ ActiveG gName - showToast ("#" <> gName) $ "member " <> localDisplayName <> " is connected" + notifyMemberConnected :: GroupInfo -> GroupMember -> m () + notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do + toView $ CRConnectedToGroupMember gInfo m + let g = groupName' gInfo + setActive $ ActiveG g + showToast ("#" <> g) $ "member " <> c <> " is connected" probeMatchingContacts :: Contact -> m () probeMatchingContacts ct = do gVar <- asks idsDrg (probe, probeId) <- withStore $ \st -> createSentProbe st gVar userId ct - sendDirectMessage (contactConn ct) $ XInfoProbe probe + void . sendDirectMessage (contactConn ct) $ XInfoProbe probe cs <- withStore (\st -> getMatchingContacts st userId ct) let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) where + sendProbeHash :: Contact -> ProbeHash -> Int64 -> m () sendProbeHash c probeHash probeId = do - sendDirectMessage (contactConn c) $ XInfoProbeCheck probeHash + void . sendDirectMessage (contactConn c) $ XInfoProbeCheck probeHash withStore $ \st -> createSentProbeHash st userId probeId c messageWarning :: Text -> m () - messageWarning = showMessageError "warning" + messageWarning = toView . CRMessageError "warning" messageError :: Text -> m () - messageError = showMessageError "error" + messageError = toView . CRMessageError "error" - newTextMessage :: ContactName -> MsgMeta -> Text -> m () - newTextMessage c meta text = do - showReceivedMessage c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta)) - showToast (c <> "> ") text + newContentMessage :: Contact -> MsgContent -> MessageId -> MsgMeta -> m () + newContentMessage ct@Contact {localDisplayName = c} mc msgId msgMeta = do + ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIRcvMsgContent mc) + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + showToast (c <> "> ") $ msgContentText mc setActive $ ActiveC c - newGroupTextMessage :: GroupName -> GroupMember -> MsgMeta -> Text -> m () - newGroupTextMessage gName GroupMember {localDisplayName = c} meta text = do - showReceivedGroupMessage gName c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta)) - showToast ("#" <> gName <> " " <> c <> "> ") text - setActive $ ActiveG gName + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContent -> MessageId -> MsgMeta -> m () + newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msgId msgMeta = do + ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvMsgContent mc) + toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + let g = groupName' gInfo + showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc + setActive $ ActiveG g - processFileInvitation :: Contact -> MsgMeta -> FileInvitation -> m () - processFileInvitation contact@Contact {localDisplayName = c} meta fInv = do + processFileInvitation :: Contact -> FileInvitation -> MessageId -> MsgMeta -> m () + processFileInvitation ct@Contact {localDisplayName = c} fInv msgId msgMeta = do -- TODO chunk size has to be sent as part of invitation chSize <- asks $ fileChunkSize . config - ft <- withStore $ \st -> createRcvFileTransfer st userId contact fInv chSize - showReceivedMessage c (snd $ broker meta) (receivedFileInvitation ft) (integrity (meta :: MsgMeta)) + ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize + ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIRcvFileInvitation ft) + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c - processGroupFileInvitation :: GroupName -> GroupMember -> MsgMeta -> FileInvitation -> m () - processGroupFileInvitation gName m@GroupMember {localDisplayName = c} meta fInv = do + processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> MessageId -> MsgMeta -> m () + processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msgId msgMeta = do chSize <- asks $ fileChunkSize . config - ft <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize - showReceivedGroupMessage gName c (snd $ broker meta) (receivedFileInvitation ft) (integrity (meta :: MsgMeta)) - showToast ("#" <> gName <> " " <> c <> "> ") "wants to send a file" - setActive $ ActiveG gName + ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize + ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvFileInvitation ft) + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + let g = groupName' gInfo + showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" + setActive $ ActiveG g processGroupInvitation :: Contact -> GroupInvitation -> m () processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) = do - when (fromRole < GRAdmin || fromRole < memRole) $ chatError (CEGroupContactRole c) - when (fromMemId == memId) $ chatError CEGroupDuplicateMemberId - group@Group {localDisplayName = gName} <- withStore $ \st -> createGroupInvitation st user ct inv - showReceivedGroupInvitation group c memRole - showToast ("#" <> gName <> " " <> c <> "> ") $ "invited you to join the group" + when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) + when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId + gInfo@GroupInfo {localDisplayName = gName} <- withStore $ \st -> createGroupInvitation st user ct inv + toView $ CRReceivedGroupInvitation gInfo ct memRole + showToast ("#" <> gName <> " " <> c <> "> ") "invited you to join the group" + + checkIntegrity :: MsgMeta -> (MsgErrorType -> m ()) -> m () + checkIntegrity MsgMeta {integrity} action = case integrity of + MsgError e -> action e + MsgOk -> pure () xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (p == p') $ do c' <- withStore $ \st -> updateContactProfile st userId c p' - showContactUpdated c c' + toView $ CRContactUpdated c c' xInfoProbe :: Contact -> Probe -> m () xInfoProbe c2 probe = do @@ -898,7 +951,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do probeMatch :: Contact -> Contact -> Probe -> m () probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe = when (p1 == p2) $ do - sendDirectMessage (contactConn c1) $ XInfoProbeOk probe + void . sendDirectMessage (contactConn c1) $ XInfoProbeOk probe mergeContacts c1 c2 xInfoProbeOk :: Contact -> Probe -> m () @@ -909,68 +962,65 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do withStore $ \st -> mergeContactRecords st userId to from - showContactsMerged to from + toView $ CRContactsMerged to from saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of - XInfo p -> - withStore $ \st -> createDirectContact st userId activeConn p + XInfo p -> do + ct <- withStore $ \st -> createDirectContact st userId activeConn p + toView $ CRContactConnecting ct -- TODO show/log error, other events in SMP confirmation _ -> pure () - xGrpMemNew :: GroupName -> GroupMember -> MemberInfo -> m () - xGrpMemNew gName m memInfo@(MemberInfo memId _ _) = do - group@Group {membership} <- withStore $ \st -> getGroup st user gName - unless (sameMemberId memId membership) $ - if isMember memId group + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> m () + xGrpMemNew gInfo m memInfo@(MemberInfo memId _ _) = do + members <- withStore $ \st -> getGroupMembers st user gInfo + unless (sameMemberId memId $ membership gInfo) $ + if isMember memId gInfo members then messageError "x.grp.mem.new error: member already exists" else do - newMember <- withStore $ \st -> createNewGroupMember st user group memInfo GCPostMember GSMemAnnounced - showJoinedGroupMemberConnecting gName m newMember + newMember <- withStore $ \st -> createNewGroupMember st user gInfo memInfo GCPostMember GSMemAnnounced + toView $ CRJoinedGroupMemberConnecting gInfo m newMember - xGrpMemIntro :: Connection -> GroupName -> GroupMember -> MemberInfo -> m () - xGrpMemIntro conn gName m memInfo@(MemberInfo memId _ _) = + xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m () + xGrpMemIntro conn gInfo m memInfo@(MemberInfo memId _ _) = do case memberCategory m of GCHostMember -> do - group <- withStore $ \st -> getGroup st user gName - if isMember memId group + members <- withStore $ \st -> getGroupMembers st 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 $ \st -> createIntroReMember st user group m memInfo groupConnId directConnId + newMember <- withStore $ \st -> createIntroReMember st user gInfo m memInfo groupConnId directConnId let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq} - sendDirectMessage conn msg + void $ sendDirectMessage conn msg withStore $ \st -> updateGroupMemberStatus st userId newMember GSMemIntroInvited _ -> messageError "x.grp.mem.intro can be only sent by host member" - xGrpMemInv :: GroupName -> GroupMember -> MemberId -> IntroInvitation -> m () - xGrpMemInv gName m memId introInv = + xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () + xGrpMemInv gInfo m memId introInv = do case memberCategory m of GCInviteeMember -> do - group <- withStore $ \st -> getGroup st user gName - case find (sameMemberId memId) $ members group of + members <- withStore $ \st -> getGroupMembers st user gInfo + case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.inv error: referenced member does not exists" Just reMember -> do - intro <- withStore $ \st -> saveIntroInvitation st reMember m introInv - case activeConn (reMember :: GroupMember) of - Nothing -> pure () -- this is not an error, introduction will be forwarded once the member is connected - Just reConn -> do - sendDirectMessage reConn $ XGrpMemFwd (memberInfo m) introInv - withStore $ \st -> updateIntroStatus st intro GMIntroInvForwarded + GroupMemberIntro {introId} <- withStore $ \st -> saveIntroInvitation st reMember m introInv + void $ sendXGrpMemInv reMember (XGrpMemFwd (memberInfo m) introInv) introId _ -> messageError "x.grp.mem.inv can be only sent by invitee member" - xGrpMemFwd :: GroupName -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gName m memInfo@(MemberInfo memId _ _) introInv@IntroInvitation {groupConnReq, directConnReq} = do - group@Group {membership} <- withStore $ \st -> getGroup st user gName - toMember <- case find (sameMemberId memId) $ members group of + xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () + xGrpMemFwd gInfo@GroupInfo {membership} m memInfo@(MemberInfo memId _ _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + members <- withStore $ \st -> getGroupMembers st user gInfo + toMember <- case find (sameMemberId memId) members of -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \st -> createNewGroupMember st user group memInfo GCPostMember GSMemAnnounced + Nothing -> withStore $ \st -> createNewGroupMember st user gInfo memInfo GCPostMember GSMemAnnounced Just m' -> pure m' withStore $ \st -> saveMemberInvitation st toMember introInv let msg = XGrpMemInfo (memberId (membership :: GroupMember)) profile @@ -978,14 +1028,14 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do directConnId <- withAgent $ \a -> joinConnection a directConnReq $ directMessage msg withStore $ \st -> createIntroToMemberContact st userId m toMember groupConnId directConnId - xGrpMemDel :: GroupName -> GroupMember -> MemberId -> m () - xGrpMemDel gName m memId = do - Group {membership, members} <- withStore $ \st -> getGroup st user gName + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> m () + xGrpMemDel gInfo@GroupInfo {membership} m memId = do + members <- withStore $ \st -> getGroupMembers st user gInfo if memberId (membership :: GroupMember) == memId then do mapM_ deleteMemberConnection members withStore $ \st -> updateGroupMemberStatus st userId membership GSMemRemoved - showDeletedMemberUser gName m + toView $ CRDeletedMemberUser gInfo m else case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.del with unknown member ID" Just member -> do @@ -995,32 +1045,32 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do else do deleteMemberConnection member withStore $ \st -> updateGroupMemberStatus st userId member GSMemRemoved - showDeletedMember gName (Just m) (Just member) + toView $ CRDeletedMember gInfo m member sameMemberId :: MemberId -> GroupMember -> Bool sameMemberId memId GroupMember {memberId} = memId == memberId - xGrpLeave :: GroupName -> GroupMember -> m () - xGrpLeave gName m = do + xGrpLeave :: GroupInfo -> GroupMember -> m () + xGrpLeave gInfo m = do deleteMemberConnection m withStore $ \st -> updateGroupMemberStatus st userId m GSMemLeft - showLeftMember gName m + toView $ CRLeftMember gInfo m - xGrpDel :: GroupName -> GroupMember -> m () - xGrpDel gName m@GroupMember {memberRole} = do - when (memberRole /= GROwner) $ chatError CEGroupUserRole + xGrpDel :: GroupInfo -> GroupMember -> m () + xGrpDel gInfo m@GroupMember {memberRole} = do + when (memberRole /= GROwner) $ throwChatError CEGroupUserRole ms <- withStore $ \st -> do - Group {members, membership} <- getGroup st user gName - updateGroupMemberStatus st userId membership GSMemGroupDeleted + members <- getGroupMembers st user gInfo + updateGroupMemberStatus st userId (membership gInfo) GSMemGroupDeleted pure members mapM_ deleteMemberConnection ms - showGroupDeleted gName m + toView $ CRGroupDeleted gInfo m parseChatMessage :: ByteString -> Either ChatError ChatMessage -parseChatMessage = first ChatErrorMessage . strDecode +parseChatMessage = first (ChatError . CEInvalidChatMessage) . strDecode sendFileChunk :: ChatMonad m => SndFileTransfer -> m () -sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} = +sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo @@ -1028,19 +1078,19 @@ sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} = withStore $ \st -> do updateSndFileStatus st ft FSComplete deleteSndFileChunks st ft - showSndFileComplete ft + toView $ CRSndFileComplete ft closeFileHandle fileId sndFiles - withAgent (`deleteConnection` agentConnId) + withAgent (`deleteConnection` acId) sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () -sendFileChunkNo ft@SndFileTransfer {agentConnId} chunkNo = do +sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do chunkBytes <- readFileChunk ft chunkNo - msgId <- withAgent $ \a -> sendMessage a agentConnId $ smpEncode FileChunk {chunkNo, chunkBytes} + msgId <- withAgent $ \a -> sendMessage a acId $ smpEncode FileChunk {chunkNo, chunkBytes} withStore $ \st -> updateSndFileChunkMsg st ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = - read_ `E.catch` (chatError . CEFileRead filePath) + read_ `E.catch` (throwChatError . CEFileRead filePath . (show :: E.SomeException -> String)) where read_ = do h <- getFileHandle fileId filePath sndFiles ReadMode @@ -1073,12 +1123,12 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus} chunkNo chunk = case fileStatus of RFSConnected RcvFileInfo {filePath} -> append_ filePath RFSCancelled _ -> pure () - _ -> chatError $ CEFileInternal "receiving file transfer not in progress" + _ -> throwChatError $ CEFileInternal "receiving file transfer not in progress" where append_ fPath = do h <- getFileHandle fileId fPath rcvFiles AppendMode E.try (liftIO $ B.hPut h chunk >> hFlush h) >>= \case - Left e -> chatError $ CEFileWrite fPath e + Left (e :: E.SomeException) -> throwChatError . CEFileWrite fPath $ show e Right () -> withStore $ \st -> updatedRcvFileChunkStored st ft chunkNo getFileHandle :: ChatMonad m => Int64 -> FilePath -> (ChatController -> TVar (Map Int64 Handle)) -> IOMode -> m Handle @@ -1105,19 +1155,19 @@ cancelRcvFileTransfer ft@RcvFileTransfer {fileId, fileStatus} = do updateRcvFileStatus st ft FSCancelled deleteRcvFileChunks st ft case fileStatus of - RFSAccepted RcvFileInfo {agentConnId} -> withAgent (`suspendConnection` agentConnId) - RFSConnected RcvFileInfo {agentConnId} -> withAgent (`suspendConnection` agentConnId) + RFSAccepted RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`suspendConnection` acId) + RFSConnected RcvFileInfo {agentConnId = AgentConnId acId} -> withAgent (`suspendConnection` acId) _ -> pure () cancelSndFileTransfer :: ChatMonad m => SndFileTransfer -> m () -cancelSndFileTransfer ft@SndFileTransfer {agentConnId, fileStatus} = +cancelSndFileTransfer ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus} = unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do withStore $ \st -> do updateSndFileStatus st ft FSCancelled deleteSndFileChunks st ft withAgent $ \a -> do - void (sendMessage a agentConnId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () - suspendConnection a agentConnId + void (sendMessage a acId $ smpEncode FileChunkCancel) `catchError` \_ -> pure () + suspendConnection a acId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () closeFileHandle fileId files = do @@ -1125,8 +1175,8 @@ closeFileHandle fileId files = do h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) mapM_ hClose h_ `E.catch` \(_ :: E.SomeException) -> pure () -chatError :: ChatMonad m => ChatErrorType -> m a -chatError = throwError . ChatError +throwChatError :: ChatMonad m => ChatErrorType -> m a +throwChatError = throwError . ChatError deleteMemberConnection :: ChatMonad m => GroupMember -> m () deleteMemberConnection m@GroupMember {activeConn} = do @@ -1135,44 +1185,118 @@ deleteMemberConnection m@GroupMember {activeConn} = do -- withStore $ \st -> deleteGroupMemberConnection st userId m forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted -sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> m () +sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> m MessageId sendDirectMessage conn chatMsgEvent = do - let msgBody = directMessage chatMsgEvent - newMsg = NewMessage {direction = MDSnd, chatMsgEventType = toChatEventTag chatMsgEvent, msgBody} - -- can be done in transaction after sendMessage, probably shouldn't - msgId <- withStore $ \st -> createNewMessage st newMsg + (msgId, msgBody) <- createSndMessage chatMsgEvent deliverMessage conn msgBody msgId + pure msgId + +createSndMessage :: ChatMonad m => ChatMsgEvent -> m (MessageId, MsgBody) +createSndMessage chatMsgEvent = do + let msgBody = directMessage chatMsgEvent + newMsg = NewMessage {direction = MDSnd, cmEventTag = toCMEventTag chatMsgEvent, msgBody} + msgId <- withStore $ \st -> createNewMessage st newMsg + pure (msgId, msgBody) directMessage :: ChatMsgEvent -> ByteString directMessage chatMsgEvent = strEncode ChatMessage {chatMsgEvent} deliverMessage :: ChatMonad m => Connection -> MsgBody -> MessageId -> m () -deliverMessage Connection {connId, agentConnId} msgBody msgId = do - agentMsgId <- withAgent $ \a -> sendMessage a agentConnId msgBody +deliverMessage conn@Connection {connId} msgBody msgId = do + agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} withStore $ \st -> createSndMsgDelivery st sndMsgDelivery msgId -sendGroupMessage :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> m () -sendGroupMessage members chatMsgEvent = do - let msgBody = directMessage chatMsgEvent - newMsg = NewMessage {direction = MDSnd, chatMsgEventType = toChatEventTag chatMsgEvent, msgBody} - msgId <- withStore $ \st -> createNewMessage st newMsg - -- TODO once scheduled delivery is implemented memberActive should be changed to memberCurrent - forM_ (map memberConn $ filter memberActive members) $ - traverse (\conn -> deliverMessage conn msgBody msgId) +sendGroupMessage :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> m MessageId +sendGroupMessage members chatMsgEvent = + sendGroupMessage' members chatMsgEvent Nothing $ pure () -saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m ChatMsgEvent +sendXGrpMemInv :: ChatMonad m => GroupMember -> ChatMsgEvent -> Int64 -> m MessageId +sendXGrpMemInv reMember chatMsgEvent introId = + sendGroupMessage' [reMember] chatMsgEvent (Just introId) $ + withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) + +sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Maybe Int64 -> m () -> m MessageId +sendGroupMessage' members chatMsgEvent introId_ postDeliver = do + (msgId, msgBody) <- createSndMessage chatMsgEvent + -- TODO collect failed deliveries into a single error + forM_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} -> + case memberConn m of + Nothing -> withStore $ \st -> createPendingGroupMessage st groupMemberId msgId introId_ + Just conn@Connection {connStatus} -> + if not (connStatus == ConnSndReady || connStatus == ConnReady) + then unless (connStatus == ConnDeleted) $ withStore (\st -> createPendingGroupMessage st groupMemberId msgId introId_) + else (deliverMessage conn msgBody msgId >> postDeliver) `catchError` const (pure ()) + pure msgId + +sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m () +sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do + pendingMessages <- withStore $ \st -> getPendingGroupMessages st groupMemberId + -- TODO ensure order - pending messages interleave with user input messages + forM_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do + deliverMessage conn msgBody msgId + withStore (\st -> deletePendingGroupMessage st groupMemberId msgId) + when (cmEventTag == XGrpMemFwd_) $ case introId_ of + Nothing -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName + Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) + +saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m (MessageId, ChatMsgEvent) saveRcvMSG Connection {connId} agentMsgMeta msgBody = do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody - let newMsg = NewMessage {direction = MDRcv, chatMsgEventType = toChatEventTag chatMsgEvent, msgBody} - agentMsgId = fst $ recipient agentMsgMeta + let agentMsgId = fst $ recipient agentMsgMeta + cmEventTag = toCMEventTag chatMsgEvent + newMsg = NewMessage {direction = MDRcv, cmEventTag, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} - withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery - pure chatMsgEvent + msgId <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery + pure (msgId, chatMsgEvent) + +sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd) +sendDirectChatItem userId contact@Contact {activeConn} chatMsgEvent ciContent = do + msgId <- sendDirectMessage activeConn chatMsgEvent + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDDirectSnd contact) $ mkNewChatItem ciContent msgId createdAt createdAt + pure $ ChatItem CIDirectSnd ciMeta ciContent + +sendGroupChatItem :: ChatMonad m => UserId -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTGroup 'MDSnd) +sendGroupChatItem userId (Group g ms) chatMsgEvent ciContent = do + msgId <- sendGroupMessage ms chatMsgEvent + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDGroupSnd g) $ mkNewChatItem ciContent msgId createdAt createdAt + pure $ ChatItem CIGroupSnd ciMeta ciContent + +saveRcvDirectChatItem :: ChatMonad m => UserId -> Contact -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTDirect 'MDRcv) +saveRcvDirectChatItem userId ct msgId MsgMeta {broker = (_, brokerTs)} ciContent = do + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDDirectRcv ct) $ mkNewChatItem ciContent msgId brokerTs createdAt + pure $ ChatItem CIDirectRcv ciMeta ciContent + +saveRcvGroupChatItem :: ChatMonad m => UserId -> GroupInfo -> GroupMember -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTGroup 'MDRcv) +saveRcvGroupChatItem userId g m msgId MsgMeta {broker = (_, brokerTs)} ciContent = do + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDGroupRcv g m) $ mkNewChatItem ciContent msgId brokerTs createdAt + pure $ ChatItem (CIGroupRcv m) ciMeta ciContent + +saveChatItem :: (ChatMonad m, MsgDirectionI d) => UserId -> ChatDirection c d -> NewChatItem d -> m (CIMeta d) +saveChatItem userId cd ci@NewChatItem {itemTs, itemText, createdAt} = do + tz <- liftIO getCurrentTimeZone + ciId <- withStore $ \st -> createNewChatItem st userId cd ci + pure $ mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt + +mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d +mkNewChatItem itemContent msgId itemTs createdAt = + NewChatItem + { createdByMsgId = if msgId == 0 then Nothing else Just msgId, + itemSent = msgDirection @d, + itemTs, + itemContent, + itemText = ciContentToText itemContent, + itemStatus = ciStatusNew, + createdAt + } allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () -allowAgentConnection conn@Connection {agentConnId} confId msg = do - withAgent $ \a -> allowConnection a agentConnId confId $ directMessage msg +allowAgentConnection conn confId msg = do + withAgent $ \a -> allowConnection a (aConnId conn) confId $ directMessage msg withStore $ \st -> updateConnectionStatus st conn ConnAccepted getCreateActiveUser :: SQLiteStore -> IO User @@ -1241,6 +1365,18 @@ notificationSubscriber = do ChatController {notifyQ, sendNotification} <- ask forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification +withUser' :: ChatMonad m => (User -> m a) -> m a +withUser' action = + asks currentUser + >>= readTVarIO + >>= maybe (throwChatError CENoActiveUser) action + +withUser :: ChatMonad m => (User -> m a) -> m a +withUser action = withUser' $ \user -> + ifM chatStarted (action user) (throwChatError CEChatNotStarted) + where + chatStarted = fmap isJust . readTVarIO =<< asks agentAsync + withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a withAgent action = asks smpAgent @@ -1258,10 +1394,21 @@ withStore action = chatCommandP :: Parser ChatCommand chatCommandP = - ("/help files" <|> "/help file" <|> "/hf") $> FilesHelp - <|> ("/help groups" <|> "/help group" <|> "/hg") $> GroupsHelp - <|> ("/help address" <|> "/ha") $> MyAddressHelp - <|> ("/help" <|> "/h") $> ChatHelp + ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile) + <|> ("/user" <|> "/u") $> ShowActiveUser + <|> "/_start" $> StartChat + <|> "/_get chats" $> APIGetChats + <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) + <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) + <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) + <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) + <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) + <|> "/_accept " *> (APIAcceptContact <$> A.decimal) + <|> "/_reject " *> (APIRejectContact <$> A.decimal) + <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles + <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups + <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress + <|> ("/help" <|> "/h") $> ChatHelp HSMain <|> ("/group #" <|> "/group " <|> "/g #" <|> "/g ") *> (NewGroup <$> groupProfile) <|> ("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole) <|> ("/join #" <|> "/join " <|> "/j #" <|> "/j ") *> (JoinGroup <$> displayName) @@ -1275,7 +1422,7 @@ chatCommandP = <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) - <|> A.char '@' *> (SendMessage <$> displayName <*> (A.space *> A.takeByteString)) + <|> A.char '@' *> (SendMessage <$> displayName <* A.space <*> A.takeByteString) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) <|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath)) @@ -1287,13 +1434,19 @@ chatCommandP = <|> ("/show_address" <|> "/sa") $> ShowMyAddress <|> ("/accept @" <|> "/accept " <|> "/ac @" <|> "/ac ") *> (AcceptContact <$> displayName) <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) - <|> ("/markdown" <|> "/m") $> MarkdownHelp + <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown <|> ("/welcome" <|> "/w") $> Welcome <|> ("/profile " <|> "/p ") *> (UpdateProfile <$> userProfile) <|> ("/profile" <|> "/p") $> ShowProfile <|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat <|> ("/version" <|> "/v") $> ShowVersion where + chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup + chatPaginationP = + (CPLast <$ "count=" <*> A.decimal) + <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) + <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) + msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' userProfile = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c3d2d7d38d..4bf983d20c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,32 +1,42 @@ {-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Controller where +import Control.Concurrent.Async (Async) import Control.Exception import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Map.Strict (Map) +import Data.Text (Text) +import GHC.Generics (Generic) import Numeric.Natural -import Simplex.Chat.Notification +import Simplex.Chat.Messages +import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) -import Simplex.Chat.Terminal import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) -import Simplex.Messaging.Agent.Protocol (AgentErrorType) +import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Protocol (CorrId) import System.IO (Handle) import UnliftIO.STM versionNumber :: String -versionNumber = "1.0.1" +versionNumber = "1.1.1" versionStr :: String versionStr = "SimpleX Chat v" <> versionNumber @@ -37,18 +47,25 @@ updateStr = "To update run: curl -o- https://raw.githubusercontent.com/simplex-c data ChatConfig = ChatConfig { agentConfig :: AgentConfig, dbPoolSize :: Int, + yesToMigrations :: Bool, tbqSize :: Natural, - fileChunkSize :: Integer + fileChunkSize :: Integer, + testView :: Bool } +data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName + deriving (Eq) + data ChatController = ChatController - { currentUser :: TVar User, + { currentUser :: TVar (Maybe User), + activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, - chatTerminal :: ChatTerminal, + agentAsync :: TVar (Maybe (Async ())), chatStore :: SQLiteStore, idsDrg :: TVar ChaChaDRG, - inputQ :: TBQueue InputEvent, + inputQ :: TBQueue String, + outputQ :: TBQueue (Maybe CorrId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), chatLock :: TMVar (), @@ -57,42 +74,194 @@ data ChatController = ChatController config :: ChatConfig } -data InputEvent = InputCommand String | InputControl Char +data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown + deriving (Show, Generic) + +instance ToJSON HelpSection where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" + +data ChatCommand + = ShowActiveUser + | CreateActiveUser Profile + | StartChat + | APIGetChats + | APIGetChat ChatType Int64 ChatPagination + | APIGetChatItems Int + | APISendMessage ChatType Int64 MsgContent + | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) + | APIDeleteChat ChatType Int64 + | APIAcceptContact Int64 + | APIRejectContact Int64 + | ChatHelp HelpSection + | Welcome + | AddContact + | Connect (Maybe AConnectionRequestUri) + | ConnectAdmin + | DeleteContact ContactName + | ListContacts + | CreateMyAddress + | DeleteMyAddress + | ShowMyAddress + | AcceptContact ContactName + | RejectContact ContactName + | SendMessage ContactName ByteString + | NewGroup GroupProfile + | AddMember GroupName ContactName GroupMemberRole + | JoinGroup GroupName + | RemoveMember GroupName ContactName + | MemberRole GroupName ContactName GroupMemberRole + | LeaveGroup GroupName + | DeleteGroup GroupName + | ListMembers GroupName + | ListGroups + | SendGroupMessage GroupName ByteString + | SendFile ContactName FilePath + | SendGroupFile GroupName FilePath + | ReceiveFile FileTransferId (Maybe FilePath) + | CancelFile FileTransferId + | FileStatus FileTransferId + | ShowProfile + | UpdateProfile Profile + | QuitChat + | ShowVersion + deriving (Show) + +data ChatResponse + = CRActiveUser {user :: User} + | CRChatStarted + | CRApiChats {chats :: [AChat]} + | CRApiChat {chat :: AChat} + | CRNewChatItem {chatItem :: AChatItem} + | CRChatItemUpdated {chatItem :: AChatItem} + | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile + | CRCmdAccepted {corr :: CorrId} + | CRCmdOk + | CRChatHelp {helpSection :: HelpSection} + | CRWelcome {user :: User} + | CRGroupCreated {groupInfo :: GroupInfo} + | CRGroupMembers {group :: Group} + | CRContactsList {contacts :: [Contact]} + | CRUserContactLink {connReqContact :: ConnReqContact} + | CRContactRequestRejected {contactRequest :: UserContactRequest} + | CRUserAcceptedGroupSent {groupInfo :: GroupInfo} + | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupsList {groups :: [GroupInfo]} + | CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact} + | CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus + | CRUserProfile {profile :: Profile} + | CRUserProfileNoChange + | CRVersionInfo + | CRInvitation {connReqInvitation :: ConnReqInvitation} + | CRSentConfirmation + | CRSentInvitation + | CRContactUpdated {fromContact :: Contact, toContact :: Contact} + | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} + | CRContactDeleted {contact :: Contact} + | CRUserContactLinkCreated {connReqContact :: ConnReqContact} + | CRUserContactLinkDeleted + | CRReceivedContactRequest {contactRequest :: UserContactRequest} + | CRAcceptingContactRequest {contact :: Contact} + | CRLeftMemberUser {groupInfo :: GroupInfo} + | CRGroupDeletedUser {groupInfo :: GroupInfo} + | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} + | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileStart {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileComplete {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} + | CRSndFileStart {sndFileTransfer :: SndFileTransfer} + | CRSndFileComplete {sndFileTransfer :: SndFileTransfer} + | CRSndFileCancelled {sndFileTransfer :: SndFileTransfer} + | CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer} + | CRSndGroupFileCancelled {sndFileTransfers :: [SndFileTransfer]} + | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} + | CRContactConnecting {contact :: Contact} + | CRContactConnected {contact :: Contact} + | CRContactAnotherClient {contact :: Contact} + | CRContactDisconnected {contact :: Contact} + | CRContactSubscribed {contact :: Contact} + | CRContactSubError {contact :: Contact, chatError :: ChatError} + | CRGroupInvitation {groupInfo :: GroupInfo} + | CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} + | CRUserJoinedGroup {groupInfo :: GroupInfo} + | CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember} + | CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember} + | CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} + | CRDeletedMemberUser {groupInfo :: GroupInfo, member :: GroupMember} + | CRLeftMember {groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupEmpty {groupInfo :: GroupInfo} + | CRGroupRemoved {groupInfo :: GroupInfo} + | CRGroupDeleted {groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSubError {groupInfo :: GroupInfo, contactName :: ContactName, chatError :: ChatError} -- TODO Contact? or GroupMember? + | CRGroupSubscribed {groupInfo :: GroupInfo} + | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} + | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} + | CRUserContactLinkSubscribed + | CRUserContactLinkSubError {chatError :: ChatError} + | CRMessageError {severity :: Text, errorMessage :: Text} + | CRChatCmdError {chatError :: ChatError} + | CRChatError {chatError :: ChatError} + deriving (Show, Generic) + +instance ToJSON ChatResponse where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" data ChatError - = ChatError ChatErrorType - | ChatErrorMessage String - | ChatErrorAgent AgentErrorType - | ChatErrorStore StoreError - deriving (Show, Exception) + = ChatError {errorType :: ChatErrorType} + | ChatErrorAgent {agentError :: AgentErrorType} + | ChatErrorStore {storeError :: StoreError} + deriving (Show, Exception, Generic) + +instance ToJSON ChatError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" data ChatErrorType - = CEGroupUserRole - | CEGroupContactRole ContactName - | CEGroupDuplicateMember ContactName + = CENoActiveUser + | CEActiveUserExists + | CEChatNotStarted + | CEInvalidConnReq + | CEInvalidChatMessage {message :: String} + | CEContactGroups {contact :: Contact, groupNames :: [GroupName]} + | CEGroupUserRole + | CEGroupContactRole {contactName :: ContactName} + | CEGroupDuplicateMember {contactName :: ContactName} | CEGroupDuplicateMemberId - | CEGroupNotJoined GroupName + | CEGroupNotJoined {groupInfo :: GroupInfo} | CEGroupMemberNotActive | CEGroupMemberUserRemoved - | CEGroupMemberNotFound ContactName - | CEGroupInternal String - | CEFileNotFound String - | CEFileAlreadyReceiving String - | CEFileAlreadyExists FilePath - | CEFileRead FilePath SomeException - | CEFileWrite FilePath SomeException - | CEFileSend Int64 AgentErrorType - | CEFileRcvChunk String - | CEFileInternal String + | CEGroupMemberNotFound {contactName :: ContactName} + | CEGroupMemberIntroNotFound {contactName :: ContactName} + | CEGroupCantResendInvitation {groupInfo :: GroupInfo, contactName :: ContactName} + | CEGroupInternal {message :: String} + | CEFileNotFound {message :: String} + | CEFileAlreadyReceiving {message :: String} + | CEFileAlreadyExists {filePath :: FilePath} + | CEFileRead {filePath :: FilePath, message :: String} + | CEFileWrite {filePath :: FilePath, message :: String} + | CEFileSend {fileId :: FileTransferId, agentError :: AgentErrorType} + | CEFileRcvChunk {message :: String} + | CEFileInternal {message :: String} | CEAgentVersion - deriving (Show, Exception) + | CECommandError {message :: String} + deriving (Show, Exception, Generic) -type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m, MonadFail m) +instance ToJSON ChatErrorType where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" + +type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) + +chatCmdError :: String -> ChatResponse +chatCmdError = CRChatCmdError . ChatError . CECommandError setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -setActive to = asks (activeTo . chatTerminal) >>= atomically . (`writeTVar` to) +setActive to = asks activeTo >>= atomically . (`writeTVar` to) unsetActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -unsetActive a = asks (activeTo . chatTerminal) >>= atomically . (`modifyTVar` unset) +unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) where unset a' = if a == a' then ActiveNone else a' diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs new file mode 100644 index 0000000000..1634c11b12 --- /dev/null +++ b/src/Simplex/Chat/Messages.hs @@ -0,0 +1,514 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeApplications #-} + +module Simplex.Chat.Messages where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Int (Int64) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Time.Clock (UTCTime) +import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) +import Data.Type.Equality +import Data.Typeable (Typeable) +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import GHC.Generics (Generic) +import Simplex.Chat.Protocol +import Simplex.Chat.Types +import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) +import Simplex.Messaging.Agent.Protocol (AgentErrorType, AgentMsgId, MsgMeta (..)) +import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Util ((<$?>)) + +data ChatType = CTDirect | CTGroup | CTContactRequest + deriving (Show, Generic) + +instance ToJSON ChatType where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" + +data ChatInfo (c :: ChatType) where + DirectChat :: Contact -> ChatInfo 'CTDirect + GroupChat :: GroupInfo -> ChatInfo 'CTGroup + ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest + +deriving instance Show (ChatInfo c) + +data JSONChatInfo + = JCInfoDirect {contact :: Contact} + | JCInfoGroup {groupInfo :: GroupInfo} + | JCInfoContactRequest {contactRequest :: UserContactRequest} + deriving (Generic) + +instance ToJSON JSONChatInfo where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" + +instance ToJSON (ChatInfo c) where + toJSON = J.toJSON . jsonChatInfo + toEncoding = J.toEncoding . jsonChatInfo + +jsonChatInfo :: ChatInfo c -> JSONChatInfo +jsonChatInfo = \case + DirectChat c -> JCInfoDirect c + GroupChat g -> JCInfoGroup g + ContactRequest g -> JCInfoContactRequest g + +data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem + { chatDir :: CIDirection c d, + meta :: CIMeta d, + content :: CIContent d + } + deriving (Show, Generic) + +instance ToJSON (ChatItem c d) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data CIDirection (c :: ChatType) (d :: MsgDirection) where + CIDirectSnd :: CIDirection 'CTDirect 'MDSnd + CIDirectRcv :: CIDirection 'CTDirect 'MDRcv + CIGroupSnd :: CIDirection 'CTGroup 'MDSnd + CIGroupRcv :: GroupMember -> CIDirection 'CTGroup 'MDRcv + +deriving instance Show (CIDirection c d) + +data JSONCIDirection + = JCIDirectSnd + | JCIDirectRcv + | JCIGroupSnd + | JCIGroupRcv {groupMember :: GroupMember} + deriving (Generic, Show) + +instance FromJSON JSONCIDirection where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" + +instance ToJSON JSONCIDirection where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" + +instance ToJSON (CIDirection c d) where + toJSON = J.toJSON . jsonCIDirection + toEncoding = J.toEncoding . jsonCIDirection + +jsonCIDirection :: CIDirection c d -> JSONCIDirection +jsonCIDirection = \case + CIDirectSnd -> JCIDirectSnd + CIDirectRcv -> JCIDirectRcv + CIGroupSnd -> JCIGroupSnd + CIGroupRcv m -> JCIGroupRcv m + +data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d) + +deriving instance Show (CChatItem c) + +instance ToJSON (CChatItem c) where + toJSON (CChatItem _ ci) = J.toJSON ci + toEncoding (CChatItem _ ci) = J.toEncoding ci + +chatItemId' :: ChatItem c d -> ChatItemId +chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId + +data ChatDirection (c :: ChatType) (d :: MsgDirection) where + CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd + CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv + CDGroupSnd :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd + CDGroupRcv :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + +data NewChatItem d = NewChatItem + { createdByMsgId :: Maybe MessageId, + itemSent :: SMsgDirection d, + itemTs :: ChatItemTs, + itemContent :: CIContent d, + itemText :: Text, + itemStatus :: CIStatus d, + createdAt :: UTCTime + } + deriving (Show) + +-- | type to show one chat with messages +data Chat c = Chat + { chatInfo :: ChatInfo c, + chatItems :: [CChatItem c], + chatStats :: ChatStats + } + deriving (Show, Generic) + +instance ToJSON (Chat c) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data AChat = forall c. AChat (SChatType c) (Chat c) + +deriving instance Show AChat + +instance ToJSON AChat where + toJSON (AChat _ c) = J.toJSON c + toEncoding (AChat _ c) = J.toEncoding c + +data ChatStats = ChatStats + { unreadCount :: Int, + minUnreadItemId :: ChatItemId + } + deriving (Show, Generic) + +instance ToJSON ChatStats where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +-- | type to show a mix of messages from multiple chats +data AChatItem = forall c d. AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) + +deriving instance Show AChatItem + +instance ToJSON AChatItem where + toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item + toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item + +data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} + deriving (Generic) + +instance ToJSON (JSONAnyChatItem c d) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data CIMeta (d :: MsgDirection) = CIMeta + { itemId :: ChatItemId, + itemTs :: ChatItemTs, + itemText :: Text, + itemStatus :: CIStatus d, + localItemTs :: ZonedTime, + createdAt :: UTCTime + } + deriving (Show, Generic) + +mkCIMeta :: ChatItemId -> Text -> CIStatus d -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d +mkCIMeta itemId itemText itemStatus tz itemTs createdAt = + let localItemTs = utcToZonedTime tz itemTs + in CIMeta {itemId, itemTs, itemText, itemStatus, localItemTs, createdAt} + +instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions + +data CIStatus (d :: MsgDirection) where + CISSndNew :: CIStatus 'MDSnd + CISSndSent :: CIStatus 'MDSnd + CISSndErrorAuth :: CIStatus 'MDSnd + CISSndError :: AgentErrorType -> CIStatus 'MDSnd + CISRcvNew :: CIStatus 'MDRcv + CISRcvRead :: CIStatus 'MDRcv + +deriving instance Show (CIStatus d) + +ciStatusNew :: forall d. MsgDirectionI d => CIStatus d +ciStatusNew = case msgDirection @d of + SMDSnd -> CISSndNew + SMDRcv -> CISRcvNew + +instance ToJSON (CIStatus d) where + toJSON = J.toJSON . jsonCIStatus + toEncoding = J.toEncoding . jsonCIStatus + +instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode + +instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d) + +instance MsgDirectionI d => StrEncoding (CIStatus d) where + strEncode = \case + CISSndNew -> "snd_new" + CISSndSent -> "snd_sent" + CISSndErrorAuth -> "snd_error_auth" + CISSndError e -> "snd_error " <> strEncode e + CISRcvNew -> "rcv_new" + CISRcvRead -> "rcv_read" + strP = (\(ACIStatus _ st) -> checkDirection st) <$?> strP + +instance StrEncoding ACIStatus where + strEncode (ACIStatus _ s) = strEncode s + strP = + A.takeTill (== ' ') >>= \case + "snd_new" -> pure $ ACIStatus SMDSnd CISSndNew + "snd_sent" -> pure $ ACIStatus SMDSnd CISSndSent + "snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth + "snd_error" -> ACIStatus SMDSnd <$> (A.space *> strP) + "rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew + "rcv_read" -> pure $ ACIStatus SMDRcv CISRcvRead + _ -> fail "bad status" + +data JSONCIStatus + = JCISSndNew + | JCISSndSent + | JCISSndErrorAuth + | JCISSndError {agentError :: AgentErrorType} + | JCISRcvNew + | JCISRcvRead + deriving (Show, Generic) + +instance ToJSON JSONCIStatus where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS" + +jsonCIStatus :: CIStatus d -> JSONCIStatus +jsonCIStatus = \case + CISSndNew -> JCISSndNew + CISSndSent -> JCISSndSent + CISSndErrorAuth -> JCISSndErrorAuth + CISSndError e -> JCISSndError e + CISRcvNew -> JCISRcvNew + CISRcvRead -> JCISRcvRead + +type ChatItemId = Int64 + +data ChatPagination + = CPLast Int + | CPAfter ChatItemId Int + | CPBefore ChatItemId Int + deriving (Show) + +type ChatItemTs = UTCTime + +data CIContent (d :: MsgDirection) where + CISndMsgContent :: MsgContent -> CIContent 'MDSnd + CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv + CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd + CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv + +deriving instance Show (CIContent d) + +ciContentToText :: CIContent d -> Text +ciContentToText = \case + CISndMsgContent mc -> msgContentText mc + CIRcvMsgContent mc -> msgContentText mc + CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath + CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName + +instance ToField (CIContent d) where + toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode + +instance ToJSON (CIContent d) where + toJSON = J.toJSON . jsonCIContent + toEncoding = J.toEncoding . jsonCIContent + +data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d) + +instance FromJSON ACIContent where + parseJSON = fmap aciContentJSON . J.parseJSON + +instance FromField ACIContent where fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 + +data JSONCIContent + = JCISndMsgContent {msgContent :: MsgContent} + | JCIRcvMsgContent {msgContent :: MsgContent} + | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} + | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} + deriving (Generic) + +instance FromJSON JSONCIContent where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" + +instance ToJSON JSONCIContent where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" + +jsonCIContent :: CIContent d -> JSONCIContent +jsonCIContent = \case + CISndMsgContent mc -> JCISndMsgContent mc + CIRcvMsgContent mc -> JCIRcvMsgContent mc + CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath + CIRcvFileInvitation ft -> JCIRcvFileInvitation ft + +aciContentJSON :: JSONCIContent -> ACIContent +aciContentJSON = \case + JCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc + JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc + JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath + JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft + +data SChatType (c :: ChatType) where + SCTDirect :: SChatType 'CTDirect + SCTGroup :: SChatType 'CTGroup + SCTContactRequest :: SChatType 'CTContactRequest + +deriving instance Show (SChatType c) + +instance TestEquality SChatType where + testEquality SCTDirect SCTDirect = Just Refl + testEquality SCTGroup SCTGroup = Just Refl + testEquality _ _ = Nothing + +class ChatTypeI (c :: ChatType) where + chatType :: SChatType c + +instance ChatTypeI 'CTDirect where chatType = SCTDirect + +instance ChatTypeI 'CTGroup where chatType = SCTGroup + +data NewMessage = NewMessage + { direction :: MsgDirection, + cmEventTag :: CMEventTag, + msgBody :: MsgBody + } + deriving (Show) + +data PendingGroupMessage = PendingGroupMessage + { msgId :: MessageId, + cmEventTag :: CMEventTag, + msgBody :: MsgBody, + introId_ :: Maybe Int64 + } + +type MessageId = Int64 + +data MsgDirection = MDRcv | MDSnd + deriving (Show, Generic) + +instance FromJSON MsgDirection where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MD" + +instance ToJSON MsgDirection where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "MD" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MD" + +instance ToField MsgDirection where toField = toField . msgDirectionInt + +data SMsgDirection (d :: MsgDirection) where + SMDRcv :: SMsgDirection 'MDRcv + SMDSnd :: SMsgDirection 'MDSnd + +deriving instance Show (SMsgDirection d) + +instance TestEquality SMsgDirection where + testEquality SMDRcv SMDRcv = Just Refl + testEquality SMDSnd SMDSnd = Just Refl + testEquality _ _ = Nothing + +instance ToField (SMsgDirection d) where toField = toField . msgDirectionInt . toMsgDirection + +toMsgDirection :: SMsgDirection d -> MsgDirection +toMsgDirection = \case + SMDRcv -> MDRcv + SMDSnd -> MDSnd + +class MsgDirectionI (d :: MsgDirection) where + msgDirection :: SMsgDirection d + +instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv + +instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd + +msgDirectionInt :: MsgDirection -> Int +msgDirectionInt = \case + MDRcv -> 0 + MDSnd -> 1 + +msgDirectionIntP :: Int64 -> Maybe MsgDirection +msgDirectionIntP = \case + 0 -> Just MDRcv + 1 -> Just MDSnd + _ -> Nothing + +data SndMsgDelivery = SndMsgDelivery + { connId :: Int64, + agentMsgId :: AgentMsgId + } + +data RcvMsgDelivery = RcvMsgDelivery + { connId :: Int64, + agentMsgId :: AgentMsgId, + agentMsgMeta :: MsgMeta + } + +data MsgMetaJSON = MsgMetaJSON + { integrity :: Text, + rcvId :: Int64, + rcvTs :: UTCTime, + serverId :: Text, + serverTs :: UTCTime, + sndId :: Int64 + } + deriving (Eq, Show, FromJSON, Generic) + +instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +msgMetaToJson :: MsgMeta -> MsgMetaJSON +msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = + MsgMetaJSON + { integrity = (decodeLatin1 . strEncode) integrity, + rcvId, + rcvTs, + serverId = (decodeLatin1 . B64.encode) serverId, + serverTs, + sndId + } + +msgMetaJson :: MsgMeta -> Text +msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson + +data MsgDeliveryStatus (d :: MsgDirection) where + MDSRcvAgent :: MsgDeliveryStatus 'MDRcv + MDSRcvAcknowledged :: MsgDeliveryStatus 'MDRcv + MDSSndPending :: MsgDeliveryStatus 'MDSnd + MDSSndAgent :: MsgDeliveryStatus 'MDSnd + MDSSndSent :: MsgDeliveryStatus 'MDSnd + MDSSndReceived :: MsgDeliveryStatus 'MDSnd + MDSSndRead :: MsgDeliveryStatus 'MDSnd + +data AMsgDeliveryStatus = forall d. AMDS (SMsgDirection d) (MsgDeliveryStatus d) + +instance (Typeable d, MsgDirectionI d) => FromField (MsgDeliveryStatus d) where + fromField = fromTextField_ msgDeliveryStatusT' + +instance ToField (MsgDeliveryStatus d) where toField = toField . serializeMsgDeliveryStatus + +serializeMsgDeliveryStatus :: MsgDeliveryStatus d -> Text +serializeMsgDeliveryStatus = \case + MDSRcvAgent -> "rcv_agent" + MDSRcvAcknowledged -> "rcv_acknowledged" + MDSSndPending -> "snd_pending" + MDSSndAgent -> "snd_agent" + MDSSndSent -> "snd_sent" + MDSSndReceived -> "snd_received" + MDSSndRead -> "snd_read" + +msgDeliveryStatusT :: Text -> Maybe AMsgDeliveryStatus +msgDeliveryStatusT = \case + "rcv_agent" -> Just $ AMDS SMDRcv MDSRcvAgent + "rcv_acknowledged" -> Just $ AMDS SMDRcv MDSRcvAcknowledged + "snd_pending" -> Just $ AMDS SMDSnd MDSSndPending + "snd_agent" -> Just $ AMDS SMDSnd MDSSndAgent + "snd_sent" -> Just $ AMDS SMDSnd MDSSndSent + "snd_received" -> Just $ AMDS SMDSnd MDSSndReceived + "snd_read" -> Just $ AMDS SMDSnd MDSSndRead + _ -> Nothing + +msgDeliveryStatusT' :: forall d. MsgDirectionI d => Text -> Maybe (MsgDeliveryStatus d) +msgDeliveryStatusT' s = + msgDeliveryStatusT s >>= \(AMDS d st) -> + case testEquality d (msgDirection @d) of + Just Refl -> Just st + _ -> Nothing + +checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) +checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of + Just Refl -> Right x + Nothing -> Left "bad direction" diff --git a/migrations/20220101_initial.sql b/src/Simplex/Chat/Migrations/M20220101_initial.hs similarity index 95% rename from migrations/20220101_initial.sql rename to src/Simplex/Chat/Migrations/M20220101_initial.hs index 27b8c5108a..b1ff292211 100644 --- a/migrations/20220101_initial.sql +++ b/src/Simplex/Chat/Migrations/M20220101_initial.hs @@ -1,3 +1,13 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220101_initial where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220101_initial :: Query +m20220101_initial = + [sql| CREATE TABLE contact_profiles ( -- remote user profile contact_profile_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, -- contact name set by remote user (not unique), this name must not contain spaces @@ -232,11 +242,12 @@ CREATE TABLE contact_requests ( CREATE TABLE messages ( message_id INTEGER PRIMARY KEY, msg_sent INTEGER NOT NULL, -- 0 for received, 1 for sent - chat_msg_event TEXT NOT NULL, -- message event type (the constructor of ChatMsgEvent) + chat_msg_event TEXT NOT NULL, -- message event tag (the constructor of CMEventTag) msg_body BLOB, -- agent message body as received or sent created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- TODO ? agent_msg_id could be NOT NULL now that pending_group_messages are separate -- message deliveries communicated with the agent, append only CREATE TABLE msg_deliveries ( msg_delivery_id INTEGER PRIMARY KEY, @@ -249,7 +260,7 @@ CREATE TABLE msg_deliveries ( ); -- TODO recovery for received messages with "rcv_agent" status - acknowledge to agent --- changes of messagy delivery status, append only +-- changes of message delivery status, append only CREATE TABLE msg_delivery_events ( msg_delivery_event_id INTEGER PRIMARY KEY, msg_delivery_id INTEGER NOT NULL REFERENCES msg_deliveries ON DELETE CASCADE, -- non UNIQUE for multiple events per msg delivery @@ -257,3 +268,4 @@ CREATE TABLE msg_delivery_events ( created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (msg_delivery_id, delivery_status) ); +|] diff --git a/src/Simplex/Chat/Migrations/M20220122_v1_1.hs b/src/Simplex/Chat/Migrations/M20220122_v1_1.hs new file mode 100644 index 0000000000..3e421b631b --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220122_v1_1.hs @@ -0,0 +1,221 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220122_v1_1 where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220122_v1_1 :: Query +m20220122_v1_1 = + [sql| +-- * pending group messages + +-- pending messages for announced (memberCurrent) but not yet connected (memberActive) group members +CREATE TABLE pending_group_messages ( + pending_group_message_id INTEGER PRIMARY KEY, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, + group_member_intro_id INTEGER REFERENCES group_member_intros ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- * chat items + +-- mutable chat_items presented to user +CREATE TABLE chat_items ( + chat_item_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- NULL for sent even if group_id is not + chat_msg_id INTEGER, -- sent as part of the message that created the item + created_by_msg_id INTEGER UNIQUE REFERENCES messages (message_id) ON DELETE SET NULL, + item_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + item_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent + item_deleted INTEGER NOT NULL DEFAULT 0, -- 1 for deleted, + item_content TEXT NOT NULL, -- JSON + item_text TEXT NOT NULL, -- textual representation + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE chat_item_messages ( + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (chat_item_id, message_id) +); + +ALTER TABLE files ADD COLUMN chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE; + +-- * created_at & updated_at for all tables + +PRAGMA ignore_check_constraints=ON; + +-- ** contact_profiles + +ALTER TABLE contact_profiles ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE contact_profiles SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE contact_profiles ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE contact_profiles SET updated_at = '1970-01-01 00:00:00'; + +-- ** users + +ALTER TABLE users ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE users SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE users ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE users SET updated_at = '1970-01-01 00:00:00'; + +-- ** display_names + +ALTER TABLE display_names ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE display_names SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE display_names ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE display_names SET updated_at = '1970-01-01 00:00:00'; + +-- ** contacts + +ALTER TABLE contacts ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE contacts SET updated_at = '1970-01-01 00:00:00'; + +-- ** sent_probes + +ALTER TABLE sent_probes ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE sent_probes SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE sent_probes ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE sent_probes SET updated_at = '1970-01-01 00:00:00'; + +-- ** sent_probe_hashes + +ALTER TABLE sent_probe_hashes ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE sent_probe_hashes SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE sent_probe_hashes ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE sent_probe_hashes SET updated_at = '1970-01-01 00:00:00'; + +-- ** received_probes + +ALTER TABLE received_probes ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE received_probes SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE received_probes ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE received_probes SET updated_at = '1970-01-01 00:00:00'; + +-- ** known_servers + +ALTER TABLE known_servers ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE known_servers SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE known_servers ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE known_servers SET updated_at = '1970-01-01 00:00:00'; + +-- ** group_profiles + +ALTER TABLE group_profiles ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE group_profiles SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE group_profiles ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE group_profiles SET updated_at = '1970-01-01 00:00:00'; + +-- ** groups + +ALTER TABLE groups ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE groups SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE groups ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE groups SET updated_at = '1970-01-01 00:00:00'; + +-- ** group_members + +ALTER TABLE group_members ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE group_members SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE group_members ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE group_members SET updated_at = '1970-01-01 00:00:00'; + +-- ** group_member_intros + +ALTER TABLE group_member_intros ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE group_member_intros SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE group_member_intros ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE group_member_intros SET updated_at = '1970-01-01 00:00:00'; + +-- ** files + +ALTER TABLE files ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE files SET updated_at = '1970-01-01 00:00:00'; + +-- ** snd_files + +ALTER TABLE snd_files ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE snd_files SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE snd_files ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE snd_files SET updated_at = '1970-01-01 00:00:00'; + +-- ** rcv_files + +ALTER TABLE rcv_files ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE rcv_files SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE rcv_files ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE rcv_files SET updated_at = '1970-01-01 00:00:00'; + +-- ** snd_file_chunks + +ALTER TABLE snd_file_chunks ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE snd_file_chunks SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE snd_file_chunks ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE snd_file_chunks SET updated_at = '1970-01-01 00:00:00'; + +-- ** rcv_file_chunks + +ALTER TABLE rcv_file_chunks ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE rcv_file_chunks SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE rcv_file_chunks ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE rcv_file_chunks SET updated_at = '1970-01-01 00:00:00'; + +-- ** connections + +ALTER TABLE connections ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE connections SET updated_at = '1970-01-01 00:00:00'; + +-- ** user_contact_links + +ALTER TABLE user_contact_links ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE user_contact_links SET updated_at = '1970-01-01 00:00:00'; + +-- ** contact_requests + +ALTER TABLE contact_requests ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE contact_requests SET updated_at = '1970-01-01 00:00:00'; + +-- ** messages + +ALTER TABLE messages ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE messages SET updated_at = '1970-01-01 00:00:00'; + +-- ** msg_deliveries + +ALTER TABLE msg_deliveries ADD COLUMN created_at TEXT CHECK (created_at NOT NULL); +UPDATE msg_deliveries SET created_at = '1970-01-01 00:00:00'; + +ALTER TABLE msg_deliveries ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE msg_deliveries SET updated_at = '1970-01-01 00:00:00'; + +-- ** msg_delivery_events + +ALTER TABLE msg_delivery_events ADD COLUMN updated_at TEXT CHECK (updated_at NOT NULL); +UPDATE msg_delivery_events SET updated_at = '1970-01-01 00:00:00'; + +PRAGMA ignore_check_constraints=OFF; +|] diff --git a/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs b/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs new file mode 100644 index 0000000000..6baca156fb --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220205_chat_item_status where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220205_chat_item_status :: Query +m20220205_chat_item_status = + [sql| +PRAGMA ignore_check_constraints=ON; + +ALTER TABLE chat_items ADD COLUMN item_status TEXT CHECK (item_status NOT NULL); + +UPDATE chat_items SET item_status = 'rcv_read' WHERE item_sent = 0; + +UPDATE chat_items SET item_status = 'snd_sent' WHERE item_sent = 1; + +PRAGMA ignore_check_constraints=OFF; +|] diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs new file mode 100644 index 0000000000..1d1c94a1fa --- /dev/null +++ b/src/Simplex/Chat/Mobile.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Mobile where + +import Control.Concurrent.STM +import Control.Monad.Reader +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.List (find) +import Foreign.C.String +import Foreign.StablePtr +import GHC.Generics (Generic) +import Simplex.Chat +import Simplex.Chat.Controller +import Simplex.Chat.Options +import Simplex.Chat.Store +import Simplex.Chat.Types +import Simplex.Messaging.Protocol (CorrId (..)) + +foreign export ccall "chat_init" cChatInit :: CString -> IO (StablePtr ChatController) + +foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString + +foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString + +-- | initialize chat controller +-- The active user has to be created and the chat has to be started before most commands can be used. +cChatInit :: CString -> IO (StablePtr ChatController) +cChatInit fp = peekCAString fp >>= chatInit >>= newStablePtr + +-- | send command to chat (same syntax as in terminal for now) +cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString +cChatSendCmd cPtr cCmd = do + c <- deRefStablePtr cPtr + cmd <- peekCAString cCmd + newCAString =<< chatSendCmd c cmd + +-- | receive message from chat (blocking) +cChatRecvMsg :: StablePtr ChatController -> IO CJSONString +cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString + +mobileChatOpts :: ChatOpts +mobileChatOpts = + ChatOpts + { dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db + smpServers = defaultSMPServers, + logging = False + } + +defaultMobileConfig :: ChatConfig +defaultMobileConfig = + defaultChatConfig + { yesToMigrations = True, + agentConfig = agentConfig defaultChatConfig {yesToMigrations = True} + } + +type CJSONString = CString + +getActiveUser_ :: SQLiteStore -> IO (Maybe User) +getActiveUser_ st = find activeUser <$> getUsers st + +chatInit :: String -> IO ChatController +chatInit dbFilePrefix = do + let f = chatStoreFile dbFilePrefix + chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations defaultMobileConfig) + user_ <- getActiveUser_ chatStore + newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} (const $ pure ()) + +chatSendCmd :: ChatController -> String -> IO JSONString +chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc + +chatRecvMsg :: ChatController -> IO JSONString +chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) + where + json (corr, resp) = LB.unpack $ J.encode APIResponse {corr, resp} + +data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} + deriving (Generic) + +instance ToJSON APIResponse where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index b0544c4f35..a75909c368 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -1,6 +1,11 @@ {-# LANGUAGE OverloadedStrings #-} -module Simplex.Chat.Options (getChatOpts, ChatOpts (..)) where +module Simplex.Chat.Options + ( ChatOpts (..), + getChatOpts, + defaultSMPServers, + ) +where import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B @@ -14,10 +19,20 @@ import Simplex.Messaging.Parsers (parseAll) import System.FilePath (combine) data ChatOpts = ChatOpts - { dbFile :: String, - smpServers :: NonEmpty SMPServer + { dbFilePrefix :: String, + smpServers :: NonEmpty SMPServer, + logging :: Bool } +defaultSMPServers :: NonEmpty SMPServer +defaultSMPServers = + L.fromList + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im" + -- "smp://Tn1b3Rr7_gErbVt2v50Y_T-PvUAi1BYAMS-62w-k9CI=@139.162.240.237" + ] + chatOpts :: FilePath -> Parser ChatOpts chatOpts appDir = ChatOpts @@ -37,13 +52,12 @@ chatOpts appDir = <> help "Comma separated list of SMP server(s) to use \ \(default: smp4.simplex.im,smp5.simplex.im,smp6.simplex.im)" - <> value - ( L.fromList - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im" - ] - ) + <> value defaultSMPServers + ) + <*> switch + ( long "log" + <> short 'l' + <> help "Enable logging" ) where defaultDbFilePath = combine appDir "simplex_v1" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 0f5cb37667..3b49f3ad7d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -9,45 +9,44 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} module Simplex.Chat.Protocol where import Control.Monad ((<=<)) import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=)) import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Lazy.Char8 as LB -import qualified Data.HashMap.Strict as H import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import GHC.Generics +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import GHC.Generics (Generic) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol +import Simplex.Chat.Util (eitherToMaybe) +import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) -data ChatDirection (p :: AParty) where - ReceivedDirectMessage :: Connection -> Maybe Contact -> ChatDirection 'Agent - SentDirectMessage :: Contact -> ChatDirection 'Client - ReceivedGroupMessage :: Connection -> GroupName -> GroupMember -> ChatDirection 'Agent - SentGroupMessage :: GroupName -> ChatDirection 'Client - SndFileConnection :: Connection -> SndFileTransfer -> ChatDirection 'Agent - RcvFileConnection :: Connection -> RcvFileTransfer -> ChatDirection 'Agent - UserContactConnection :: Connection -> UserContact -> ChatDirection 'Agent +data ConnectionEntity + = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} + | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} + | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} + | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} + | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} + deriving (Eq, Show) -deriving instance Eq (ChatDirection p) - -deriving instance Show (ChatDirection p) - -fromConnection :: ChatDirection 'Agent -> Connection -fromConnection = \case - ReceivedDirectMessage conn _ -> conn - ReceivedGroupMessage conn _ _ -> conn - SndFileConnection conn _ -> conn - RcvFileConnection conn _ -> conn - UserContactConnection conn _ -> conn +updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity +updateEntityConnStatus connEntity connStatus = case connEntity of + RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = st c}) <$> ct_) + RcvGroupMsgConnection c gInfo m@GroupMember {activeConn = c'} -> RcvGroupMsgConnection (st c) gInfo m {activeConn = st <$> c'} + SndFileConnection c ft -> SndFileConnection (st c) ft + RcvFileConnection c ft -> RcvFileConnection (st c) ft + UserContactConnection c uc -> UserContactConnection (st c) uc + where + st c = c {connStatus} -- chat message is sent as JSON with these properties data AppMessage = AppMessage @@ -108,9 +107,16 @@ instance ToJSON MsgContentType where toJSON = strToJSON toEncoding = strToJEncoding +-- TODO - include tag and original JSON into MCUnknown so that information is not lost +-- so when it serializes back it is the same as it was and chat upgrade makes it readable data MsgContent = MCText Text | MCUnknown deriving (Eq, Show) +msgContentText :: MsgContent -> Text +msgContentText = \case + MCText t -> t + MCUnknown -> unknownMsgType + toMsgContentType :: MsgContent -> MsgContentType toMsgContentType = \case MCText _ -> MCText_ @@ -161,6 +167,7 @@ data CMEventTag | XInfoProbeCheck_ | XInfoProbeOk_ | XOk_ + deriving (Eq, Show) instance StrEncoding CMEventTag where strEncode = \case @@ -234,8 +241,15 @@ toCMEventTag = \case XInfoProbeOk _ -> XInfoProbeOk_ XOk -> XOk_ -toChatEventTag :: ChatMsgEvent -> Text -toChatEventTag = decodeLatin1 . strEncode . toCMEventTag +cmEventTagT :: Text -> Maybe CMEventTag +cmEventTagT = eitherToMaybe . strDecode . encodeUtf8 + +serializeCMEventTag :: CMEventTag -> Text +serializeCMEventTag = decodeLatin1 . strEncode + +instance FromField CMEventTag where fromField = fromTextField_ cmEventTagT + +instance ToField CMEventTag where toField = toField . serializeCMEventTag appToChatMessage :: AppMessage -> Either String ChatMessage appToChatMessage AppMessage {event, params} = do @@ -243,7 +257,7 @@ appToChatMessage AppMessage {event, params} = do chatMsgEvent <- msg eventTag pure ChatMessage {chatMsgEvent} where - p :: FromJSON a => Text -> Either String a + p :: FromJSON a => J.Key -> Either String a p key = JT.parseEither (.: key) params msg = \case XMsgNew_ -> XMsgNew <$> p "content" @@ -271,9 +285,9 @@ appToChatMessage AppMessage {event, params} = do chatToAppMessage :: ChatMessage -> AppMessage chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} where - event = toChatEventTag chatMsgEvent - o :: [(Text, J.Value)] -> J.Object - o = H.fromList + event = serializeCMEventTag . toCMEventTag $ chatMsgEvent + o :: [(J.Key, J.Value)] -> J.Object + o = JM.fromList params = case chatMsgEvent of XMsgNew content -> o ["content" .= content] XFile fileInv -> o ["file" .= fileInv] @@ -290,9 +304,9 @@ chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} XGrpMemCon memId -> o ["memberId" .= memId] XGrpMemConAll memId -> o ["memberId" .= memId] XGrpMemDel memId -> o ["memberId" .= memId] - XGrpLeave -> H.empty - XGrpDel -> H.empty + XGrpLeave -> JM.empty + XGrpDel -> JM.empty XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] - XOk -> H.empty + XOk -> JM.empty diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 8efad9c137..2d625d7ebb 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1,8 +1,10 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -11,6 +13,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store @@ -25,7 +28,9 @@ module Simplex.Chat.Store createDirectContact, getContactGroupNames, deleteContact, + getContactByName, getContact, + getContactIdByName, updateUserProfile, updateContactProfile, getUserContacts, @@ -35,6 +40,7 @@ module Simplex.Chat.Store getUserContactLink, createContactRequest, getContactRequest, + getContactRequestIdByName, deleteContactRequest, createAcceptedContact, getLiveSndFileTransfers, @@ -42,11 +48,16 @@ module Simplex.Chat.Store getPendingSndChunks, getPendingConnections, getContactConnections, - getConnectionChatDirection, + getConnectionEntity, updateConnectionStatus, createNewGroup, createGroupInvitation, getGroup, + getGroupInfo, + getGroupIdByName, + getGroupByName, + getGroupInfoByName, + getGroupMembers, deleteGroup, getUserGroups, getUserGroupDetails, @@ -88,6 +99,7 @@ module Simplex.Chat.Store createRcvFileChunk, updatedRcvFileChunkStored, deleteRcvFileChunks, + updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, createNewMessage, @@ -95,6 +107,17 @@ module Simplex.Chat.Store createNewMessageAndRcvMsgDelivery, createSndMsgDeliveryEvent, createRcvMsgDeliveryEvent, + createPendingGroupMessage, + getPendingGroupMessages, + deletePendingGroupMessage, + createNewChatItem, + getChatPreviews, + getDirectChat, + getGroupChat, + getChatItemIdByAgentMsgId, + updateDirectChatItem, + updateDirectChatItemsRead, + updateGroupChatItemsRead, ) where @@ -105,42 +128,57 @@ import qualified Control.Exception as E import Control.Monad.Except import Control.Monad.IO.Unlift import Crypto.Random (ChaChaDRG, randomBytesGenerate) +import Data.Aeson (ToJSON) +import qualified Data.Aeson as J +import Data.Bifunctor (first) import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import Data.Either (rights) -import Data.FileEmbed (embedDir, makeRelativeToProject) import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, sortBy) +import Data.List (find, sortBy, sortOn) import Data.Maybe (listToMaybe) +import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeUtf8) -import Data.Time.Clock (UTCTime, getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), SQLError, (:.) (..)) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) +import Data.Type.Equality +import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) +import GHC.Generics (Generic) +import Simplex.Chat.Messages +import Simplex.Chat.Migrations.M20220101_initial +import Simplex.Chat.Migrations.M20220122_v1_1 +import Simplex.Chat.Migrations.M20220205_chat_item_status import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AParty (..), AgentMsgId, ConnId, InvitationId, MsgMeta (..)) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, withTransaction) +import Simplex.Chat.Util (eitherToMaybe) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..)) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Util (bshow, liftIOEither, (<$$>)) -import System.FilePath (takeBaseName, takeExtension, takeFileName) +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (liftIOEither, (<$$>)) +import System.FilePath (takeFileName) import UnliftIO.STM +schemaMigrations :: [(String, Query)] +schemaMigrations = + [ ("20220101_initial", m20220101_initial), + ("20220122_v1_1", m20220122_v1_1), + ("20220205_chat_item_status", m20220205_chat_item_status) + ] + -- | The list of migrations in ascending order by date migrations :: [Migration] -migrations = - sortBy (compare `on` name) . map migration . filter sqlFile $ - $(makeRelativeToProject "migrations" >>= embedDir) +migrations = sortBy (compare `on` name) $ map migration schemaMigrations where - sqlFile (file, _) = takeExtension file == ".sql" - migration (file, qStr) = Migration {name = takeBaseName file, up = decodeUtf8 qStr} + migration (name, query) = Migration {name = name, up = fromQuery query} -createStore :: FilePath -> Int -> IO SQLiteStore +createStore :: FilePath -> Int -> Bool -> IO SQLiteStore createStore dbFilePath poolSize = createSQLiteStore dbFilePath poolSize migrations chatStoreFile :: FilePath -> FilePath @@ -152,7 +190,7 @@ checkConstraint err action = action `E.catch` (pure . Left . handleSQLError err) handleSQLError :: StoreError -> SQLError -> StoreError handleSQLError err e | DB.sqlError e == DB.ErrorConstraint = err - | otherwise = SEInternal $ bshow e + | otherwise = SEInternalError $ show e insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" @@ -162,12 +200,25 @@ type StoreMonad m = (MonadUnliftIO m, MonadError StoreError m) createUser :: StoreMonad m => SQLiteStore -> Profile -> Bool -> m User createUser st Profile {displayName, fullName} activeUser = liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do - DB.execute db "INSERT INTO users (local_display_name, active_user, contact_id) VALUES (?, ?, 0)" (displayName, activeUser) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO users (local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,0,?,?)" + (displayName, activeUser, currentTs, currentTs) userId <- insertedRowId db - DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (displayName, displayName, userId) - DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + DB.execute + db + "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, displayName, userId, currentTs, currentTs) + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) profileId <- insertedRowId db - DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user) VALUES (?, ?, ?, ?)" (profileId, displayName, userId, True) + DB.execute + db + "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (profileId, displayName, userId, True, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName) @@ -198,65 +249,56 @@ setActiveUser st userId = do createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> m () createDirectConnection st userId agentConnId = - liftIO . withTransaction st $ \db -> - void $ createContactConnection_ db userId agentConnId Nothing 0 + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + void $ createContactConnection_ db userId agentConnId Nothing 0 currentTs -createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> IO Connection -createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing +createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection +createContactConnection_ db userId = do createConnection_ db userId ConnContact Nothing --- field types coincidentally match, but the first element here is user ID and not connection ID as in ConnectionRow -type InsertedConnectionRow = ConnectionRow - -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Int -> IO Connection -createConnection_ db userId connType entityId agentConnId viaContact connLevel = do - createdAt <- getCurrentTime +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection +createConnection_ db userId connType entityId acId viaContact connLevel currentTs = do DB.execute db [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, conn_status, conn_type, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?); + contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (insertConnParams createdAt) + ( (userId, acId, connLevel, viaContact, ConnNew, connType) + :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) + ) connId <- insertedRowId db - pure Connection {connId, agentConnId, connType, entityId, viaContact, connLevel, connStatus = ConnNew, createdAt} + pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, connLevel, connStatus = ConnNew, createdAt = currentTs} where - insertConnParams :: UTCTime -> InsertedConnectionRow - insertConnParams createdAt = - ( userId, - agentConnId, - connLevel, - viaContact, - ConnNew, - connType, - ent ConnContact, - ent ConnMember, - ent ConnSndFile, - ent ConnRcvFile, - ent ConnUserContact, - createdAt - ) ent ct = if connType == ct then entityId else Nothing -createDirectContact :: StoreMonad m => SQLiteStore -> UserId -> Connection -> Profile -> m () -createDirectContact st userId Connection {connId} profile = - void $ - liftIOEither . withTransaction st $ \db -> - createContact_ db userId connId profile Nothing +createDirectContact :: StoreMonad m => SQLiteStore -> UserId -> Connection -> Profile -> m Contact +createDirectContact st userId activeConn@Connection {connId} profile = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, contactId, _) <- ExceptT $ createContact_ db userId connId profile Nothing createdAt + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt} -createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> IO (Either StoreError (Text, Int64, Int64)) -createContact_ db userId connId Profile {displayName, fullName} viaGroup = +createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64)) +createContact_ db userId connId Profile {displayName, fullName} viaGroup currentTs = withLocalDisplayName db userId displayName $ \ldn -> do - DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) profileId <- insertedRowId db - DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group) VALUES (?,?,?,?)" (profileId, ldn, userId, viaGroup) + DB.execute + db + "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (profileId, ldn, userId, viaGroup, currentTs, currentTs) contactId <- insertedRowId db - DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, connId) + DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) pure (ldn, contactId, profileId) -getContactGroupNames :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactName -> m [GroupName] -getContactGroupNames st userId displayName = +getContactGroupNames :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [GroupName] +getContactGroupNames st userId Contact {contactId} = liftIO . withTransaction st $ \db -> do map fromOnly <$> DB.query @@ -265,55 +307,43 @@ getContactGroupNames st userId displayName = SELECT DISTINCT g.local_display_name FROM groups g JOIN group_members m ON m.group_id = g.group_id - WHERE g.user_id = ? AND m.local_display_name = ? + WHERE g.user_id = ? AND m.contact_id = ? |] - (userId, displayName) + (userId, contactId) -deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactName -> m () -deleteContact st userId displayName = +deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m () +deleteContact st userId Contact {contactId, localDisplayName} = liftIO . withTransaction st $ \db -> do - DB.executeNamed + DB.execute db [sql| DELETE FROM connections WHERE connection_id IN ( SELECT connection_id FROM connections c - JOIN contacts cs ON c.contact_id = cs.contact_id - WHERE cs.user_id = :user_id AND cs.local_display_name = :display_name + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? ) |] - [":user_id" := userId, ":display_name" := displayName] - DB.executeNamed - db - [sql| - DELETE FROM contacts - WHERE user_id = :user_id AND local_display_name = :display_name - |] - [":user_id" := userId, ":display_name" := displayName] - DB.executeNamed - db - [sql| - DELETE FROM display_names - WHERE user_id = :user_id AND local_display_name = :display_name - |] - [":user_id" := userId, ":display_name" := displayName] + (userId, contactId) + DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) + DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) -getContact :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Contact -getContact st userId localDisplayName = - liftIOEither . withTransaction st $ \db -> runExceptT $ getContact_ db userId localDisplayName - -updateUserProfile :: StoreMonad m => SQLiteStore -> User -> Profile -> m User -updateUserProfile st u@User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} +updateUserProfile :: StoreMonad m => SQLiteStore -> User -> Profile -> m () +updateUserProfile st User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} | displayName == newName = liftIO . withTransaction st $ \db -> - updateContactProfile_ db userId userContactId p' $> (u :: User) {profile = p'} + updateContactProfile_ db userId userContactId p' | otherwise = liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do - DB.execute db "UPDATE users SET local_display_name = ? WHERE user_id = ?" (newName, userId) - DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (newName, newName, userId) - updateContactProfile_ db userId userContactId p' - updateContact_ db userId userContactId localDisplayName newName - pure . Right $ (u :: User) {localDisplayName = newName, profile = p'} + currentTs <- getCurrentTime + DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + DB.execute + db + "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (newName, newName, userId, currentTs, currentTs) + updateContactProfile_' db userId userContactId p' currentTs + updateContact_ db userId userContactId localDisplayName newName currentTs + pure $ Right () updateContactProfile :: StoreMonad m => SQLiteStore -> UserId -> Contact -> Profile -> m Contact updateContactProfile st userId c@Contact {contactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} @@ -323,18 +353,25 @@ updateContactProfile st userId c@Contact {contactId, localDisplayName, profile = | otherwise = liftIOEither . withTransaction st $ \db -> withLocalDisplayName db userId newName $ \ldn -> do - updateContactProfile_ db userId contactId p' - updateContact_ db userId contactId localDisplayName ldn + currentTs <- getCurrentTime + updateContactProfile_' db userId contactId p' currentTs + updateContact_ db userId contactId localDisplayName ldn currentTs pure $ (c :: Contact) {localDisplayName = ldn, profile = p'} updateContactProfile_ :: DB.Connection -> UserId -> Int64 -> Profile -> IO () -updateContactProfile_ db userId contactId Profile {displayName, fullName} = +updateContactProfile_ db userId contactId profile = do + currentTs <- getCurrentTime + updateContactProfile_' db userId contactId profile currentTs + +updateContactProfile_' :: DB.Connection -> UserId -> Int64 -> Profile -> UTCTime -> IO () +updateContactProfile_' db userId contactId Profile {displayName, fullName} updatedAt = do DB.executeNamed db [sql| UPDATE contact_profiles SET display_name = :display_name, - full_name = :full_name + full_name = :full_name, + updated_at = :updated_at WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contacts @@ -344,71 +381,63 @@ updateContactProfile_ db userId contactId Profile {displayName, fullName} = |] [ ":display_name" := displayName, ":full_name" := fullName, + ":updated_at" := updatedAt, ":user_id" := userId, ":contact_id" := contactId ] -updateContact_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> IO () -updateContact_ db userId contactId displayName newName = do - DB.execute db "UPDATE contacts SET local_display_name = ? WHERE user_id = ? AND contact_id = ?" (newName, userId, contactId) - DB.execute db "UPDATE group_members SET local_display_name = ? WHERE user_id = ? AND contact_id = ?" (newName, userId, contactId) +updateContact_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () +updateContact_ db userId contactId displayName newName updatedAt = do + DB.execute + db + "UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" + (newName, updatedAt, userId, contactId) + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" + (newName, updatedAt, userId, contactId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) +type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, UTCTime) + +toContact :: ContactRow :. ConnectionRow -> Contact +toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) = + let profile = Profile {displayName, fullName} + activeConn = toConnection connRow + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} + +toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact +toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) = + let profile = Profile {displayName, fullName} + in case toMaybeConnection connRow of + Just activeConn -> + Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} + _ -> Left $ SEContactNotReady localDisplayName + -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getContact_ :: DB.Connection -> UserId -> ContactName -> ExceptT StoreError IO Contact -getContact_ db userId localDisplayName = do - c@Contact {contactId} <- getContactRec_ - activeConn <- getConnection_ contactId - pure $ (c :: Contact) {activeConn} - where - getContactRec_ :: ExceptT StoreError IO Contact - getContactRec_ = ExceptT $ do - toContact - <$> DB.queryNamed - db - [sql| - SELECT c.contact_id, p.display_name, p.full_name, c.via_group - FROM contacts c - JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id - WHERE c.user_id = :user_id AND c.local_display_name = :local_display_name AND c.is_user = :is_user - |] - [":user_id" := userId, ":local_display_name" := localDisplayName, ":is_user" := False] - getConnection_ :: Int64 -> ExceptT StoreError IO Connection - getConnection_ contactId = ExceptT $ do - connection - <$> DB.queryNamed - db - [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, - c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at - FROM connections c - WHERE c.user_id = :user_id AND c.contact_id == :contact_id - ORDER BY c.connection_id DESC - LIMIT 1 - |] - [":user_id" := userId, ":contact_id" := contactId] - toContact :: [(Int64, Text, Text, Maybe Int64)] -> Either StoreError Contact - toContact [(contactId, displayName, fullName, viaGroup)] = - let profile = Profile {displayName, fullName} - in Right Contact {contactId, localDisplayName, profile, activeConn = undefined, viaGroup} - toContact _ = Left $ SEContactNotFound localDisplayName - connection :: [ConnectionRow] -> Either StoreError Connection - connection (connRow : _) = Right $ toConnection connRow - connection _ = Left $ SEContactNotReady localDisplayName +getContactByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Contact +getContactByName st userId localDisplayName = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + cId <- ExceptT $ getContactIdByName_ db userId localDisplayName + ExceptT $ getContact_ db userId cId getUserContacts :: MonadUnliftIO m => SQLiteStore -> User -> m [Contact] getUserContacts st User {userId} = liftIO . withTransaction st $ \db -> do - contactNames <- map fromOnly <$> DB.query db "SELECT local_display_name FROM contacts WHERE user_id = ?" (Only userId) - rights <$> mapM (runExceptT . getContact_ db userId) contactNames + contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId) + rights <$> mapM (getContact_ db userId) contactIds createUserContactLink :: StoreMonad m => SQLiteStore -> UserId -> ConnId -> ConnReqContact -> m () createUserContactLink st userId agentConnId cReq = liftIOEither . checkConstraint SEDuplicateContactLink . withTransaction st $ \db -> do - DB.execute db "INSERT INTO user_contact_links (user_id, conn_req_contact) VALUES (?, ?)" (userId, cReq) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" + (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - Right () <$ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing 0 + Right () <$ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing 0 currentTs getUserContactLinkConnections :: StoreMonad m => SQLiteStore -> UserId -> m [Connection] getUserContactLinkConnections st userId = @@ -420,7 +449,7 @@ getUserContactLinkConnections st userId = SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM connections c - JOIN user_contact_links uc ON c.user_contact_link_id == uc.user_contact_link_id + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id WHERE c.user_id = :user_id AND uc.user_id = :user_id AND uc.local_display_name = '' @@ -490,54 +519,91 @@ getUserContactLink st userId = connReq [Only cReq] = Right cReq connReq _ = Left SEUserContactLinkNotFound -createContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> m ContactName +createContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> m UserContactRequest createContactRequest st userId userContactId invId Profile {displayName, fullName} = liftIOEither . withTransaction st $ \db -> - withLocalDisplayName db userId displayName $ \ldn -> do - DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + join <$> withLocalDisplayName db userId displayName (createContactRequest' db) + where + createContactRequest' db ldn = do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) profileId <- insertedRowId db DB.execute db [sql| INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id) VALUES (?,?,?,?,?) + (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) |] - (userContactId, invId, profileId, ldn, userId) - pure ldn + (userContactId, invId, profileId, ldn, userId, currentTs, currentTs) + contactRequestId <- insertedRowId db + getContactRequest_ db userId contactRequestId -getContactRequest :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m UserContactRequest -getContactRequest st userId localDisplayName = +getContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m UserContactRequest +getContactRequest st userId contactRequestId = liftIOEither . withTransaction st $ \db -> - contactReq - <$> DB.query - db - [sql| - SELECT cr.contact_request_id, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id - FROM contact_requests cr - JOIN connections c USING (user_contact_link_id) - WHERE cr.user_id = ? - AND cr.local_display_name = ? - |] - (userId, localDisplayName) - where - contactReq [(contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, profileId)] = - Right UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, profileId, localDisplayName} - contactReq _ = Left $ SEContactRequestNotFound localDisplayName + getContactRequest_ db userId contactRequestId -deleteContactRequest :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactName -> m () -deleteContactRequest st userId localDisplayName = +getContactRequest_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError UserContactRequest) +getContactRequest_ db userId contactRequestId = + firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ + DB.query + db + [sql| + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.contact_request_id = ? + |] + (userId, contactRequestId) + +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime) + +toContactRequest :: ContactRequestRow -> UserContactRequest +toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt) = do + let profile = Profile {displayName, fullName} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt} + +getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 +getContactRequestIdByName st userId cName = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SEContactRequestNotFoundByName cName) $ + DB.query db "SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, cName) + +deleteContactRequest :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () +deleteContactRequest st userId contactRequestId = + liftIO . withTransaction st $ \db -> do + DB.execute + db + [sql| + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ( + SELECT local_display_name FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + |] + (userId, userId, contactRequestId) + DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) + +createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> m Contact +createAcceptedContact st userId agentConnId localDisplayName profileId profile = liftIO . withTransaction st $ \db -> do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - -createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> m () -createAcceptedContact st userId agentConnId localDisplayName profileId = - liftIO . withTransaction st $ \db -> do - DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - DB.execute db "INSERT INTO contacts (user_id, local_display_name, contact_profile_id) VALUES (?,?,?)" (userId, localDisplayName, profileId) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (userId, localDisplayName, profileId, currentTs, currentTs) contactId <- insertedRowId db - void $ createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 currentTs + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs} getLiveSndFileTransfers :: MonadUnliftIO m => SQLiteStore -> User -> m [SndFileTransfer] getLiveSndFileTransfers st User {userId} = @@ -604,24 +670,22 @@ getPendingConnections st User {userId} = |] [":user_id" := userId, ":conn_type" := ConnContact] -getContactConnections :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m [Connection] -getContactConnections st userId displayName = +getContactConnections :: StoreMonad m => SQLiteStore -> UserId -> Contact -> m [Connection] +getContactConnections st userId Contact {contactId} = liftIOEither . withTransaction st $ \db -> connections - <$> DB.queryNamed + <$> DB.query db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM connections c - JOIN contacts cs ON c.contact_id == cs.contact_id - WHERE c.user_id = :user_id - AND cs.user_id = :user_id - AND cs.local_display_name == :display_name + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? |] - [":user_id" := userId, ":display_name" := displayName] + (userId, userId, contactId) where - connections [] = Left $ SEContactNotFound displayName + connections [] = Left $ SEContactNotFound contactId connections rows = Right $ map toConnection rows type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, UTCTime) @@ -629,9 +693,9 @@ type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, May type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toConnection :: ConnectionRow -> Connection -toConnection (connId, agentConnId, connLevel, viaContact, connStatus, connType, contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId, createdAt) = +toConnection (connId, acId, connLevel, viaContact, connStatus, connType, contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId, createdAt) = let entityId = entityId_ connType - in Connection {connId, agentConnId, connLevel, viaContact, connStatus, connType, entityId, createdAt} + in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, connStatus, connType, entityId, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -648,12 +712,12 @@ toMaybeConnection _ = Nothing getMatchingContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [Contact] getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName}} = liftIO . withTransaction st $ \db -> do - contactNames <- + contactIds <- map fromOnly <$> DB.queryNamed db [sql| - SELECT ct.local_display_name + SELECT ct.contact_id FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id @@ -664,41 +728,51 @@ getMatchingContacts st userId Contact {contactId, profile = Profile {displayName ":display_name" := displayName, ":full_name" := fullName ] - rights <$> mapM (runExceptT . getContact_ db userId) contactNames + rights <$> mapM (getContact_ db userId) contactIds createSentProbe :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> UserId -> Contact -> m (Probe, Int64) createSentProbe st gVar userId _to@Contact {contactId} = liftIOEither . withTransaction st $ \db -> createWithRandomBytes 32 gVar $ \probe -> do - DB.execute db "INSERT INTO sent_probes (contact_id, probe, user_id) VALUES (?,?,?)" (contactId, probe, userId) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO sent_probes (contact_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (contactId, probe, userId, currentTs, currentTs) (Probe probe,) <$> insertedRowId db createSentProbeHash :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> Contact -> m () createSentProbeHash st userId probeId _to@Contact {contactId} = - liftIO . withTransaction st $ \db -> - DB.execute db "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, user_id) VALUES (?,?,?)" (probeId, contactId, userId) + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (probeId, contactId, userId, currentTs, currentTs) matchReceivedProbe :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Probe -> m (Maybe Contact) matchReceivedProbe st userId _from@Contact {contactId} (Probe probe) = liftIO . withTransaction st $ \db -> do let probeHash = C.sha256Hash probe - contactNames <- + contactIds <- map fromOnly <$> DB.query db [sql| - SELECT c.local_display_name + SELECT c.contact_id FROM contacts c JOIN received_probes r ON r.contact_id = c.contact_id WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL |] (userId, probeHash) - DB.execute db "INSERT INTO received_probes (contact_id, probe, probe_hash, user_id) VALUES (?,?,?,?)" (contactId, probe, probeHash, userId) - case contactNames of + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO received_probes (contact_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (contactId, probe, probeHash, userId, currentTs, currentTs) + case contactIds of [] -> pure Nothing - cName : _ -> - either (const Nothing) Just - <$> runExceptT (getContact_ db userId cName) + cId : _ -> eitherToMaybe <$> getContact_ db userId cId matchReceivedProbeHash :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> ProbeHash -> m (Maybe (Contact, Probe)) matchReceivedProbeHash st userId _from@Contact {contactId} (ProbeHash probeHash) = @@ -707,76 +781,90 @@ matchReceivedProbeHash st userId _from@Contact {contactId} (ProbeHash probeHash) DB.query db [sql| - SELECT c.local_display_name, r.probe + SELECT c.contact_id, r.probe FROM contacts c JOIN received_probes r ON r.contact_id = c.contact_id WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL |] (userId, probeHash) - DB.execute db "INSERT INTO received_probes (contact_id, probe_hash, user_id) VALUES (?,?,?)" (contactId, probeHash, userId) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO received_probes (contact_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (contactId, probeHash, userId, currentTs, currentTs) case namesAndProbes of [] -> pure Nothing - (cName, probe) : _ -> + (cId, probe) : _ -> either (const Nothing) (Just . (,Probe probe)) - <$> runExceptT (getContact_ db userId cName) + <$> getContact_ db userId cId matchSentProbe :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Probe -> m (Maybe Contact) matchSentProbe st userId _from@Contact {contactId} (Probe probe) = liftIO . withTransaction st $ \db -> do - contactNames <- + contactIds <- map fromOnly <$> DB.query db [sql| - SELECT c.local_display_name + SELECT c.contact_id FROM contacts c JOIN sent_probes s ON s.contact_id = c.contact_id JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id WHERE c.user_id = ? AND s.probe = ? AND h.contact_id = ? |] (userId, probe, contactId) - case contactNames of + case contactIds of [] -> pure Nothing - cName : _ -> - either (const Nothing) Just - <$> runExceptT (getContact_ db userId cName) + cId : _ -> eitherToMaybe <$> getContact_ db userId cId mergeContactRecords :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Contact -> m () mergeContactRecords st userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = liftIO . withTransaction st $ \db -> do - DB.execute db "UPDATE connections SET contact_id = ? WHERE contact_id = ? AND user_id = ?" (toContactId, fromContactId, userId) - DB.execute db "UPDATE connections SET via_contact = ? WHERE via_contact = ? AND user_id = ?" (toContactId, fromContactId, userId) - DB.execute db "UPDATE group_members SET invited_by = ? WHERE invited_by = ? AND user_id = ?" (toContactId, fromContactId, userId) + currentTs <- getCurrentTime + DB.execute + db + "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) DB.executeNamed db [sql| UPDATE group_members SET contact_id = :to_contact_id, local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id) + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), + updated_at = :updated_at WHERE contact_id = :from_contact_id AND user_id = :user_id |] [ ":to_contact_id" := toContactId, ":from_contact_id" := fromContactId, - ":user_id" := userId + ":user_id" := userId, + ":updated_at" := currentTs ] DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) -getConnectionChatDirection :: StoreMonad m => SQLiteStore -> User -> ConnId -> m (ChatDirection 'Agent) -getConnectionChatDirection st User {userId, userContactId} agentConnId = +getConnectionEntity :: StoreMonad m => SQLiteStore -> User -> ConnId -> m ConnectionEntity +getConnectionEntity st User {userId, userContactId} agentConnId = liftIOEither . withTransaction st $ \db -> runExceptT $ do c@Connection {connType, entityId} <- getConnection_ db case entityId of Nothing -> if connType == ConnContact - then pure $ ReceivedDirectMessage c Nothing - else throwError $ SEInternal $ "connection " <> bshow connType <> " without entity" + then pure $ RcvDirectMsgConnection c Nothing + else throwError $ SEInternalError $ "connection " <> show connType <> " without entity" Just entId -> case connType of - ConnMember -> uncurry (ReceivedGroupMessage c) <$> getGroupAndMember_ db entId c - ConnContact -> ReceivedDirectMessage c . Just <$> getContactRec_ db entId c + ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ db entId c + ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ db entId c ConnSndFile -> SndFileConnection c <$> getConnSndFileTransfer_ db entId c ConnRcvFile -> RcvFileConnection c <$> ExceptT (getRcvFileTransfer_ db userId entId) ConnUserContact -> UserContactConnection c <$> getUserContact_ db entId @@ -795,45 +883,55 @@ getConnectionChatDirection st User {userId, userContactId} agentConnId = (userId, agentConnId) connection :: [ConnectionRow] -> Either StoreError Connection connection (connRow : _) = Right $ toConnection connRow - connection _ = Left $ SEConnectionNotFound agentConnId + connection _ = Left . SEConnectionNotFound $ AgentConnId agentConnId getContactRec_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO Contact getContactRec_ db contactId c = ExceptT $ do - toContact contactId c + toContact' contactId c <$> DB.query db [sql| - SELECT c.local_display_name, p.display_name, p.full_name, c.via_group + SELECT c.local_display_name, p.display_name, p.full_name, c.via_group, c.created_at FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe Int64)] -> Either StoreError Contact - toContact contactId activeConn [(localDisplayName, displayName, fullName, viaGroup)] = + toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe Int64, UTCTime)] -> Either StoreError Contact + toContact' contactId activeConn [(localDisplayName, displayName, fullName, viaGroup, createdAt)] = let profile = Profile {displayName, fullName} - in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup} - toContact _ _ _ = Left $ SEInternal "referenced contact not found" - getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupName, GroupMember) + in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} + toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" + getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ db groupMemberId c = ExceptT $ do - toGroupAndMember c - <$> DB.query + firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + DB.query db [sql| SELECT - g.local_display_name, + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, + -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id JOIN groups g ON g.group_id = m.group_id - WHERE m.group_member_id = ? + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? |] - (Only groupMemberId) - toGroupAndMember :: Connection -> [Only GroupName :. GroupMemberRow] -> Either StoreError (GroupName, GroupMember) - toGroupAndMember c [Only groupName :. memberRow] = - let member = toGroupMember userContactId memberRow - in Right (groupName, (member :: GroupMember) {activeConn = Just c}) - toGroupAndMember _ _ = Left $ SEInternal "referenced group member not found" + (groupMemberId, userId, userContactId) + toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember c (groupInfoRow :. memberRow) = + let groupInfo = toGroupInfo userContactId groupInfoRow + member = toGroupMember userContactId memberRow + in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getConnSndFileTransfer_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer getConnSndFileTransfer_ db fileId Connection {connId} = ExceptT $ @@ -852,7 +950,7 @@ getConnectionChatDirection st User {userId, userContactId} agentConnId = sndFileTransfer_ :: Int64 -> Int64 -> [(FileStatus, String, Integer, Integer, FilePath, Maybe ContactName, Maybe ContactName)] -> Either StoreError SndFileTransfer sndFileTransfer_ fileId connId [(fileStatus, fileName, fileSize, chunkSize, filePath, contactName_, memberName_)] = case contactName_ <|> memberName_ of - Just recipientDisplayName -> Right SndFileTransfer {..} + Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId = AgentConnId agentConnId} Nothing -> Left $ SESndFileInvalid fileId sndFileTransfer_ fileId _ _ = Left $ SESndFileNotFound fileId getUserContact_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UserContact @@ -873,113 +971,88 @@ getConnectionChatDirection st User {userId, userContactId} agentConnId = updateConnectionStatus :: MonadUnliftIO m => SQLiteStore -> Connection -> ConnStatus -> m () updateConnectionStatus st Connection {connId} connStatus = - liftIO . withTransaction st $ \db -> - DB.execute db "UPDATE connections SET conn_status = ? WHERE connection_id = ?" (connStatus, connId) + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) -- | creates completely new group with a single member - the current user -createNewGroup :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m Group +createNewGroup :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m GroupInfo createNewGroup st gVar user groupProfile = liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do let GroupProfile {displayName, fullName} = groupProfile uId = userId user - DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (displayName, displayName, uId) - DB.execute db "INSERT INTO group_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, displayName, uId, currentTs, currentTs) + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) profileId <- insertedRowId db - DB.execute db "INSERT INTO groups (local_display_name, user_id, group_profile_id) VALUES (?, ?, ?)" (displayName, uId, profileId) + DB.execute + db + "INSERT INTO groups (local_display_name, user_id, group_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, uId, profileId, currentTs, currentTs) groupId <- insertedRowId db memberId <- randomBytes gVar 12 - membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser - pure $ Right Group {groupId, localDisplayName = displayName, groupProfile, members = [], membership} + membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs + pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: - StoreMonad m => SQLiteStore -> User -> Contact -> GroupInvitation -> m Group -createGroupInvitation st user@User {userId} contact GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = + StoreMonad m => SQLiteStore -> User -> Contact -> GroupInvitation -> m GroupInfo +createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = liftIOEither . withTransaction st $ \db -> do - getGroupInvitationLdn_ db >>= \case + getInvitationGroupId_ db >>= \case Nothing -> createGroupInvitation_ db -- TODO treat the case that the invitation details could've changed - Just localDisplayName -> runExceptT $ fst <$> getGroup_ db user localDisplayName + Just gId -> getGroupInfo_ db user gId where - getGroupInvitationLdn_ :: DB.Connection -> IO (Maybe GroupName) - getGroupInvitationLdn_ db = + getInvitationGroupId_ :: DB.Connection -> IO (Maybe Int64) + getInvitationGroupId_ db = listToMaybe . map fromOnly - <$> DB.query db "SELECT local_display_name FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1;" (connRequest, userId) - createGroupInvitation_ :: DB.Connection -> IO (Either StoreError Group) + <$> DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) + createGroupInvitation_ :: DB.Connection -> IO (Either StoreError GroupInfo) createGroupInvitation_ db = do let GroupProfile {displayName, fullName} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> do - DB.execute db "INSERT INTO group_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) profileId <- insertedRowId db - DB.execute db "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id) VALUES (?, ?, ?, ?)" (profileId, localDisplayName, connRequest, userId) + DB.execute + db + "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (profileId, localDisplayName, connRequest, userId, currentTs, currentTs) groupId <- insertedRowId db - member <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown - membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact $ contactId contact) - pure Group {groupId, localDisplayName, groupProfile, members = [member], membership} + _ <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown currentTs + membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) currentTs + pure $ GroupInfo {groupId, localDisplayName, groupProfile, membership, createdAt = currentTs} -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Group -getGroup st user localDisplayName = - liftIOEither . withTransaction st $ \db -> runExceptT $ fst <$> getGroup_ db user localDisplayName +getGroupByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Group +getGroupByName st user gName = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + groupId <- ExceptT $ getGroupIdByName_ db user gName + ExceptT $ getGroup_ db user groupId -getGroup_ :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO (Group, Maybe ConnReqInvitation) -getGroup_ db User {userId, userContactId} localDisplayName = do - (g@Group {groupId}, cReq) <- getGroupRec_ - allMembers <- getMembers_ groupId - (members, membership) <- liftEither $ splitUserMember_ allMembers - pure (g {members, membership}, cReq) - where - getGroupRec_ :: ExceptT StoreError IO (Group, Maybe ConnReqInvitation) - getGroupRec_ = ExceptT $ do - toGroup - <$> DB.query - db - [sql| - SELECT g.group_id, p.display_name, p.full_name, g.inv_queue_info - FROM groups g - JOIN group_profiles p ON p.group_profile_id = g.group_profile_id - WHERE g.local_display_name = ? AND g.user_id = ? - |] - (localDisplayName, userId) - toGroup :: [(Int64, GroupName, Text, Maybe ConnReqInvitation)] -> Either StoreError (Group, Maybe ConnReqInvitation) - toGroup [(groupId, displayName, fullName, cReq)] = - let groupProfile = GroupProfile {displayName, fullName} - in Right (Group {groupId, localDisplayName, groupProfile, members = undefined, membership = undefined}, cReq) - toGroup _ = Left $ SEGroupNotFound localDisplayName - getMembers_ :: Int64 -> ExceptT StoreError IO [GroupMember] - getMembers_ groupId = ExceptT $ do - Right . map toContactMember - <$> DB.query - db - [sql| - SELECT - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, - c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.group_member_id = m.group_member_id - ) - WHERE m.group_id = ? AND m.user_id = ? - |] - (groupId, userId) - toContactMember :: (GroupMemberRow :. MaybeConnectionRow) -> GroupMember - toContactMember (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow} - splitUserMember_ :: [GroupMember] -> Either StoreError ([GroupMember], GroupMember) - splitUserMember_ allMembers = - let (b, a) = break ((== Just userContactId) . memberContactId) allMembers - in case a of - [] -> Left SEGroupWithoutUser - u : ms -> Right (b <> ms, u) +getGroup :: StoreMonad m => SQLiteStore -> User -> Int64 -> m Group +getGroup st user groupId = + liftIOEither . withTransaction st $ \db -> getGroup_ db user groupId + +getGroup_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError Group) +getGroup_ db user groupId = runExceptT $ do + gInfo <- ExceptT $ getGroupInfo_ db user groupId + members <- liftIO $ getGroupMembers_ db user gInfo + pure $ Group gInfo members deleteGroup :: MonadUnliftIO m => SQLiteStore -> User -> Group -> m () -deleteGroup st User {userId} Group {groupId, members, localDisplayName} = +deleteGroup st User {userId} (Group GroupInfo {groupId, localDisplayName} members) = liftIO . withTransaction st $ \db -> do forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId m) DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) @@ -990,39 +1063,95 @@ deleteGroup st User {userId} Group {groupId, members, localDisplayName} = getUserGroups :: MonadUnliftIO m => SQLiteStore -> User -> m [Group] getUserGroups st user@User {userId} = liftIO . withTransaction st $ \db -> do - groupNames <- map fromOnly <$> DB.query db "SELECT local_display_name FROM groups WHERE user_id = ?" (Only userId) - map fst . rights <$> mapM (runExceptT . getGroup_ db user) groupNames + groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) + rights <$> mapM (getGroup_ db user) groupIds -getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> UserId -> m [(GroupName, Text, GroupMemberStatus)] -getUserGroupDetails st userId = +getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> User -> m [GroupInfo] +getUserGroupDetails st User {userId, userContactId} = liftIO . withTransaction st $ \db -> - DB.query + map (toGroupInfo userContactId) + <$> DB.query + db + [sql| + SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members m USING (group_id) + JOIN contact_profiles mp USING (contact_profile_id) + WHERE g.user_id = ? AND m.contact_id = ? + |] + (userId, userContactId) + +getGroupInfoByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m GroupInfo +getGroupInfoByName st user gName = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + gId <- ExceptT $ getGroupIdByName_ db user gName + ExceptT $ getGroupInfo_ db user gId + +type GroupInfoRow = (Int64, GroupName, GroupName, Text, UTCTime) :. GroupMemberRow + +toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, createdAt) :. userMemberRow) = + let membership = toGroupMember userContactId userMemberRow + in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership, createdAt} + +getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember] +getGroupMembers st user gInfo = liftIO . withTransaction st $ \db -> getGroupMembers_ db user gInfo + +getGroupMembers_ :: DB.Connection -> User -> GroupInfo -> IO [GroupMember] +getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do + map toContactMember + <$> DB.query db [sql| - SELECT g.local_display_name, p.full_name, m.member_status - FROM groups g - JOIN group_profiles p USING (group_profile_id) - JOIN group_members m USING (group_id) - WHERE g.user_id = ? AND m.member_category = 'user' + SELECT + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.group_member_id = m.group_member_id + ) + WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) |] - (Only userId) + (groupId, userId, userContactId) + where + toContactMember :: (GroupMemberRow :. MaybeConnectionRow) -> GroupMember + toContactMember (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow} +-- TODO no need to load all members to find the member who invited the used, +-- instead of findFromContact there could be a query getGroupInvitation :: StoreMonad m => SQLiteStore -> User -> GroupName -> m ReceivedGroupInvitation getGroupInvitation st user localDisplayName = liftIOEither . withTransaction st $ \db -> runExceptT $ do - (Group {membership, members, groupProfile}, cReq) <- getGroup_ db user localDisplayName + cReq <- getConnRec_ db user + groupId <- ExceptT $ getGroupIdByName_ db user localDisplayName + Group groupInfo@GroupInfo {membership} members <- ExceptT $ getGroup_ db user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined case (cReq, findFromContact (invitedBy membership) members) of (Just connRequest, Just fromMember) -> - pure ReceivedGroupInvitation {fromMember, userMember = membership, connRequest, groupProfile} + pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} _ -> throwError SEGroupInvitationNotFound where + getConnRec_ :: DB.Connection -> User -> ExceptT StoreError IO (Maybe ConnReqInvitation) + getConnRec_ db User {userId} = ExceptT $ do + firstRow fromOnly (SEGroupNotFoundByName localDisplayName) $ + DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.local_display_name = ? AND g.user_id = ?" (localDisplayName, userId) findFromContact :: InvitedBy -> [GroupMember] -> Maybe GroupMember findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) findFromContact _ = const Nothing type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text) + toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) = let memberProfile = Profile {displayName, fullName} @@ -1030,46 +1159,59 @@ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, membe activeConn = Nothing in GroupMember {..} +toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember +toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName) = + Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) +toMaybeGroupMember _ _ = Nothing + createContactMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> m GroupMember createContactMember st gVar user groupId contact memberRole agentConnId connRequest = liftIOEither . withTransaction st $ \db -> createWithRandomId gVar $ \memId -> do - member@GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact (MemberIdRole (MemberId memId) memberRole) GCInviteeMember GSMemInvited IBUser (Just connRequest) - void $ createMemberConnection_ db (userId user) groupMemberId agentConnId Nothing 0 + currentTs <- getCurrentTime + member@GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact (MemberIdRole (MemberId memId) memberRole) GCInviteeMember GSMemInvited IBUser (Just connRequest) currentTs + void $ createMemberConnection_ db (userId user) groupMemberId agentConnId Nothing 0 currentTs pure member getMemberInvitation :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Maybe ConnReqInvitation) getMemberInvitation st User {userId} groupMemberId = liftIO . withTransaction st $ \db -> join . listToMaybe . map fromOnly - <$> DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?;" (groupMemberId, userId) + <$> DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) createMemberConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> ConnId -> m () createMemberConnection st userId GroupMember {groupMemberId} agentConnId = - liftIO . withTransaction st $ \db -> - void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs updateGroupMemberStatus :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> GroupMemberStatus -> m () updateGroupMemberStatus st userId GroupMember {groupMemberId} memStatus = - liftIO . withTransaction st $ \db -> + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime DB.executeNamed db [sql| UPDATE group_members - SET member_status = :member_status + SET member_status = :member_status, updated_at = :updated_at WHERE user_id = :user_id AND group_member_id = :group_member_id |] [ ":user_id" := userId, ":group_member_id" := groupMemberId, - ":member_status" := memStatus + ":member_status" := memStatus, + ":updated_at" := currentTs ] -- | add new member with profile -createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> Group -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> m GroupMember -createNewGroupMember st user@User {userId} group memInfo@(MemberInfo _ _ Profile {displayName, fullName}) memCategory memStatus = +createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> m GroupMember +createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName}) memCategory memStatus = liftIOEither . withTransaction st $ \db -> withLocalDisplayName db userId displayName $ \localDisplayName -> do - DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) memProfileId <- insertedRowId db let newMember = NewGroupMember @@ -1081,13 +1223,13 @@ createNewGroupMember st user@User {userId} group memInfo@(MemberInfo _ _ Profile memContactId = Nothing, memProfileId } - createNewMember_ db user group newMember + createNewMember_ db user gInfo newMember currentTs -createNewMember_ :: DB.Connection -> User -> Group -> NewGroupMember -> IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember createNewMember_ db User {userId, userContactId} - Group {groupId} + GroupInfo {groupId} NewGroupMember { memInfo = MemberInfo memberId memberRole memberProfile, memCategory = memberCategory, @@ -1096,7 +1238,8 @@ createNewMember_ localDisplayName, memContactId = memberContactId, memProfileId - } = do + } + createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing DB.execute @@ -1104,9 +1247,10 @@ createNewMember_ [sql| INSERT INTO group_members (group_id, member_id, member_role, member_category, member_status, - invited_by, user_id, local_display_name, contact_profile_id, contact_id) VALUES (?,?,?,?,?,?,?,?,?,?) + invited_by, user_id, local_display_name, contact_profile_id, contact_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memProfileId, memberContactId) + (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memProfileId, memberContactId, createdAt, createdAt) groupMemberId <- insertedRowId db pure GroupMember {..} @@ -1118,74 +1262,83 @@ deleteGroupMemberConnection_ :: DB.Connection -> UserId -> GroupMember -> IO () deleteGroupMemberConnection_ db userId GroupMember {groupMemberId} = DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId) -createIntroductions :: MonadUnliftIO m => SQLiteStore -> Group -> GroupMember -> m [GroupMemberIntro] -createIntroductions st Group {members} toMember = do +createIntroductions :: MonadUnliftIO m => SQLiteStore -> [GroupMember] -> GroupMember -> m [GroupMemberIntro] +createIntroductions st members toMember = do let reMembers = filter (\m -> memberCurrent m && groupMemberId m /= groupMemberId toMember) members if null reMembers then pure [] - else liftIO . withTransaction st $ \db -> - mapM (insertIntro_ db) reMembers + else liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + mapM (insertIntro_ db currentTs) reMembers where - insertIntro_ :: DB.Connection -> GroupMember -> IO GroupMemberIntro - insertIntro_ db reMember = do + insertIntro_ :: DB.Connection -> UTCTime -> GroupMember -> IO GroupMemberIntro + insertIntro_ db ts reMember = do DB.execute db [sql| INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status) VALUES (?,?,?) + (re_group_member_id, to_group_member_id, intro_status, created_at, updated_at) + VALUES (?,?,?,?,?) |] - (groupMemberId reMember, groupMemberId toMember, GMIntroPending) + (groupMemberId reMember, groupMemberId toMember, GMIntroPending, ts, ts) introId <- insertedRowId db pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} -updateIntroStatus :: MonadUnliftIO m => SQLiteStore -> GroupMemberIntro -> GroupMemberIntroStatus -> m () -updateIntroStatus st GroupMemberIntro {introId} introStatus' = - liftIO . withTransaction st $ \db -> +updateIntroStatus :: MonadUnliftIO m => SQLiteStore -> Int64 -> GroupMemberIntroStatus -> m () +updateIntroStatus st introId introStatus = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime DB.executeNamed db [sql| UPDATE group_member_intros - SET intro_status = :intro_status + SET intro_status = :intro_status, updated_at = :updated_at WHERE group_member_intro_id = :intro_id |] - [":intro_status" := introStatus', ":intro_id" := introId] + [":intro_status" := introStatus, ":updated_at" := currentTs, ":intro_id" := introId] saveIntroInvitation :: StoreMonad m => SQLiteStore -> GroupMember -> GroupMember -> IntroInvitation -> m GroupMemberIntro saveIntroInvitation st reMember toMember introInv = do liftIOEither . withTransaction st $ \db -> runExceptT $ do intro <- getIntroduction_ db reMember toMember - liftIO $ + liftIO $ do + currentTs <- getCurrentTime DB.executeNamed db [sql| UPDATE group_member_intros SET intro_status = :intro_status, group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info + direct_queue_info = :direct_queue_info, + updated_at = :updated_at WHERE group_member_intro_id = :intro_id |] [ ":intro_status" := GMIntroInvReceived, ":group_queue_info" := groupConnReq introInv, ":direct_queue_info" := directConnReq introInv, + ":updated_at" := currentTs, ":intro_id" := introId intro ] pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} saveMemberInvitation :: StoreMonad m => SQLiteStore -> GroupMember -> IntroInvitation -> m () saveMemberInvitation st GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = - liftIO . withTransaction st $ \db -> + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime DB.executeNamed db [sql| UPDATE group_members SET member_status = :member_status, group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info + direct_queue_info = :direct_queue_info, + updated_at = :updated_at WHERE group_member_id = :group_member_id |] [ ":member_status" := GSMemIntroInvited, ":group_queue_info" := groupConnReq, ":direct_queue_info" := directConnReq, + ":updated_at" := currentTs, ":group_member_id" := groupMemberId ] @@ -1207,12 +1360,13 @@ getIntroduction_ db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -createIntroReMember :: StoreMonad m => SQLiteStore -> User -> Group -> GroupMember -> MemberInfo -> ConnId -> ConnId -> m GroupMember -createIntroReMember st user@User {userId} group@Group {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId = +createIntroReMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> GroupMember -> MemberInfo -> ConnId -> ConnId -> m GroupMember +createIntroReMember st user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId = liftIOEither . withTransaction st $ \db -> runExceptT $ do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn - Connection {connId = directConnId} <- liftIO $ createContactConnection_ db userId directAgentConnId memberContactId cLevel - (localDisplayName, contactId, memProfileId) <- ExceptT $ createContact_ db userId directConnId memberProfile (Just groupId) + currentTs <- liftIO getCurrentTime + Connection {connId = directConnId} <- liftIO $ createContactConnection_ db userId directAgentConnId memberContactId cLevel currentTs + (localDisplayName, contactId, memProfileId) <- ExceptT $ createContact_ db userId directConnId memberProfile (Just groupId) currentTs liftIO $ do let newMember = NewGroupMember @@ -1224,56 +1378,54 @@ createIntroReMember st user@User {userId} group@Group {groupId} _host@GroupMembe memContactId = Just contactId, memProfileId } - member <- createNewMember_ db user group newMember - conn <- createMemberConnection_ db userId (groupMemberId member) groupAgentConnId memberContactId cLevel + member <- createNewMember_ db user gInfo newMember currentTs + conn <- createMemberConnection_ db userId (groupMemberId member) groupAgentConnId memberContactId cLevel currentTs pure (member :: GroupMember) {activeConn = Just conn} createIntroToMemberContact :: StoreMonad m => SQLiteStore -> UserId -> GroupMember -> GroupMember -> ConnId -> ConnId -> m () createIntroToMemberContact st userId GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} groupAgentConnId directAgentConnId = liftIO . withTransaction st $ \db -> do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn - void $ createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel - Connection {connId = directConnId} <- createContactConnection_ db userId directAgentConnId viaContactId cLevel - contactId <- createMemberContact_ db directConnId - updateMember_ db contactId + currentTs <- getCurrentTime + void $ createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs + Connection {connId = directConnId} <- createContactConnection_ db userId directAgentConnId viaContactId cLevel currentTs + contactId <- createMemberContact_ db directConnId currentTs + updateMember_ db contactId currentTs where - createMemberContact_ :: DB.Connection -> Int64 -> IO Int64 - createMemberContact_ db connId = do - DB.executeNamed + createMemberContact_ :: DB.Connection -> Int64 -> UTCTime -> IO Int64 + createMemberContact_ db connId ts = do + DB.execute db [sql| - INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id) - SELECT contact_profile_id, group_id, :local_display_name, :user_id + INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at) + SELECT contact_profile_id, group_id, ?, ?, ?, ? FROM group_members - WHERE group_member_id = :group_member_id + WHERE group_member_id = ? |] - [ ":group_member_id" := groupMemberId, - ":local_display_name" := localDisplayName, - ":user_id" := userId - ] + (localDisplayName, userId, ts, ts, groupMemberId) contactId <- insertedRowId db - DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, connId) + DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, ts, connId) pure contactId - updateMember_ :: DB.Connection -> Int64 -> IO () - updateMember_ db contactId = + updateMember_ :: DB.Connection -> Int64 -> UTCTime -> IO () + updateMember_ db contactId ts = DB.executeNamed db [sql| UPDATE group_members - SET contact_id = :contact_id + SET contact_id = :contact_id, updated_at = :updated_at WHERE group_member_id = :group_member_id |] - [":contact_id" := contactId, ":group_member_id" := groupMemberId] + [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] -createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> IO Connection +createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection createMemberConnection_ db userId groupMemberId = createConnection_ db userId ConnMember (Just groupMemberId) -createContactMember_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> IO GroupMember +createContactMember_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> UTCTime -> IO GroupMember createContactMember_ db user groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy = createContactMemberInv_ db user groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy Nothing -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ConnReqInvitation -> IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy connRequest = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ConnReqInvitation -> UTCTime -> IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy connRequest createdAt = do insertMember_ groupMemberId <- insertedRowId db let memberProfile = profile' userOrContact @@ -1288,12 +1440,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me [sql| INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_profile_id, contact_id, sent_inv_queue_info) + user_id, local_display_name, contact_profile_id, contact_id, sent_inv_queue_info, created_at, updated_at) VALUES (:group_id,:member_id,:member_role,:member_category,:member_status,:invited_by, :user_id,:local_display_name, (SELECT contact_profile_id FROM contacts WHERE contact_id = :contact_id), - :contact_id, :sent_inv_queue_info) + :contact_id, :sent_inv_queue_info, :created_at, :updated_at) |] [ ":group_id" := groupId, ":member_id" := memberId, @@ -1304,10 +1456,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ":user_id" := userId, ":local_display_name" := localDisplayName' userOrContact, ":contact_id" := contactId' userOrContact, - ":sent_inv_queue_info" := connRequest + ":sent_inv_queue_info" := connRequest, + ":created_at" := createdAt, + ":updated_at" := createdAt ] -getViaGroupMember :: MonadUnliftIO m => SQLiteStore -> User -> Contact -> m (Maybe (GroupName, GroupMember)) +getViaGroupMember :: MonadUnliftIO m => SQLiteStore -> User -> Contact -> m (Maybe (GroupInfo, GroupMember)) getViaGroupMember st User {userId, userContactId} Contact {contactId} = liftIO . withTransaction st $ \db -> toGroupAndMember @@ -1315,7 +1469,14 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = db [sql| SELECT - g.local_display_name, + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, + -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, @@ -1324,30 +1485,34 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = JOIN contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id LEFT JOIN connections c ON c.connection_id = ( SELECT max(cc.connection_id) FROM connections cc where cc.group_member_id = m.group_member_id ) - WHERE ct.user_id = ? AND ct.contact_id = ? + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? |] - (userId, contactId) + (userId, contactId, userContactId) where - toGroupAndMember :: [Only GroupName :. GroupMemberRow :. MaybeConnectionRow] -> Maybe (GroupName, GroupMember) - toGroupAndMember [Only groupName :. memberRow :. connRow] = - let member = toGroupMember userContactId memberRow - in Just (groupName, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + toGroupAndMember :: [GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow] -> Maybe (GroupInfo, GroupMember) + toGroupAndMember [groupInfoRow :. memberRow :. connRow] = + let groupInfo = toGroupInfo userContactId groupInfoRow + member = toGroupMember userContactId memberRow + in Just (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) toGroupAndMember _ = Nothing getViaGroupContact :: MonadUnliftIO m => SQLiteStore -> User -> GroupMember -> m (Maybe Contact) getViaGroupContact st User {userId} GroupMember {groupMemberId} = liftIO . withTransaction st $ \db -> - toContact + toContact' <$> DB.query db [sql| SELECT - ct.contact_id, ct.local_display_name, p.display_name, p.full_name, ct.via_group, + ct.contact_id, ct.local_display_name, p.display_name, p.full_name, ct.via_group, ct.created_at, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct @@ -1363,42 +1528,58 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact :: [(Int64, ContactName, Text, Text, Maybe Int64) :. ConnectionRow] -> Maybe Contact - toContact [(contactId, localDisplayName, displayName, fullName, viaGroup) :. connRow] = + toContact' :: [(Int64, ContactName, Text, Text, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact + toContact' [(contactId, localDisplayName, displayName, fullName, viaGroup, createdAt) :. connRow] = let profile = Profile {displayName, fullName} activeConn = toConnection connRow - in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup} - toContact _ = Nothing + in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} + toContact' _ = Nothing createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m SndFileTransfer -createSndFileTransfer st userId Contact {contactId, localDisplayName = recipientDisplayName} filePath FileInvitation {fileName, fileSize} agentConnId chunkSize = +createSndFileTransfer st userId Contact {contactId, localDisplayName = recipientDisplayName} filePath FileInvitation {fileName, fileSize} acId chunkSize = liftIO . withTransaction st $ \db -> do - DB.execute db "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size) VALUES (?, ?, ?, ?, ?, ?)" (userId, contactId, fileName, filePath, fileSize, chunkSize) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) fileId <- insertedRowId db - Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId + Connection {connId} <- createSndFileConnection_ db userId fileId acId let fileStatus = FSNew - DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id) VALUES (?, ?, ?)" (fileId, fileStatus, connId) - pure SndFileTransfer {..} + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, fileStatus, connId, currentTs, currentTs) + pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId acId} -createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Group -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 -createSndGroupFileTransfer st userId Group {groupId} ms filePath fileSize chunkSize = +createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 +createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize chunkSize = liftIO . withTransaction st $ \db -> do let fileName = takeFileName filePath - DB.execute db "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size) VALUES (?, ?, ?, ?, ?, ?)" (userId, groupId, fileName, filePath, fileSize, chunkSize) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) fileId <- insertedRowId db forM_ ms $ \(GroupMember {groupMemberId}, agentConnId, _) -> do Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId - DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id) VALUES (?, ?, ?, ?)" (fileId, FSNew, connId, groupMemberId) + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (fileId, FSNew, connId, groupMemberId, currentTs, currentTs) pure fileId createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection -createSndFileConnection_ db userId fileId agentConnId = - createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing 0 +createSndFileConnection_ db userId fileId agentConnId = do + currentTs <- getCurrentTime + createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing 0 currentTs updateSndFileStatus :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> FileStatus -> m () updateSndFileStatus st SndFileTransfer {fileId, connId} status = - liftIO . withTransaction st $ \db -> - DB.execute db "UPDATE snd_files SET file_status = ? WHERE file_id = ? AND connection_id = ?" (status, fileId, connId) + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ?" (status, currentTs, fileId, connId) createSndFileChunk :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> m (Maybe Integer) createSndFileChunk st SndFileTransfer {fileId, connId, fileSize, chunkSize} = @@ -1413,32 +1594,39 @@ createSndFileChunk st SndFileTransfer {fileId, connId, fileSize, chunkSize} = [] -> Just 1 n : _ -> if n * chunkSize >= fileSize then Nothing else Just (n + 1) insertChunk db = \case - Just chunkNo -> DB.execute db "INSERT OR REPLACE INTO snd_file_chunks (file_id, connection_id, chunk_number) VALUES (?, ?, ?)" (fileId, connId, chunkNo) + Just chunkNo -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT OR REPLACE INTO snd_file_chunks (file_id, connection_id, chunk_number, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, connId, chunkNo, currentTs, currentTs) Nothing -> pure () updateSndFileChunkMsg :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> Integer -> AgentMsgId -> m () updateSndFileChunkMsg st SndFileTransfer {fileId, connId} chunkNo msgId = - liftIO . withTransaction st $ \db -> + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime DB.execute db [sql| UPDATE snd_file_chunks - SET chunk_agent_msg_id = ? + SET chunk_agent_msg_id = ?, updated_at = ? WHERE file_id = ? AND connection_id = ? AND chunk_number = ? |] - (msgId, fileId, connId, chunkNo) + (msgId, currentTs, fileId, connId, chunkNo) updateSndFileChunkSent :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> AgentMsgId -> m () updateSndFileChunkSent st SndFileTransfer {fileId, connId} msgId = - liftIO . withTransaction st $ \db -> + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime DB.execute db [sql| UPDATE snd_file_chunks - SET chunk_sent = 1 + SET chunk_sent = 1, updated_at = ? WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id = ? |] - (fileId, connId, msgId) + (currentTs, fileId, connId, msgId) deleteSndFileChunks :: MonadUnliftIO m => SQLiteStore -> SndFileTransfer -> m () deleteSndFileChunks st SndFileTransfer {fileId, connId} = @@ -1448,17 +1636,31 @@ deleteSndFileChunks st SndFileTransfer {fileId, connId} = createRcvFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FileInvitation -> Integer -> m RcvFileTransfer createRcvFileTransfer st userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = liftIO . withTransaction st $ \db -> do - DB.execute db "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size) VALUES (?, ?, ?, ?, ?)" (userId, contactId, fileName, fileSize, chunkSize) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (userId, contactId, fileName, fileSize, chunkSize, currentTs, currentTs) fileId <- insertedRowId db - DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info) VALUES (?, ?, ?)" (fileId, FSNew, fileConnReq) + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, file_queue_info, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, FSNew, fileConnReq, currentTs, currentTs) pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize} createRcvGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> FileInvitation -> Integer -> m RcvFileTransfer createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = liftIO . withTransaction st $ \db -> do - DB.execute db "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size) VALUES (?, ?, ?, ?, ?)" (userId, groupId, fileName, fileSize, chunkSize) + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (userId, groupId, fileName, fileSize, chunkSize, currentTs, currentTs) fileId <- insertedRowId db - DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id) VALUES (?, ?, ?, ?)" (fileId, FSNew, fileConnReq, groupMemberId) + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize} getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer @@ -1485,7 +1687,7 @@ getRcvFileTransfer_ db userId fileId = (userId, fileId) where rcvFileTransfer :: - [(FileStatus, ConnReqInvitation, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe ConnId)] -> + [(FileStatus, ConnReqInvitation, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> Either StoreError RcvFileTransfer rcvFileTransfer [(fileStatus', fileConnReq, fileName, fileSize, chunkSize, contactName_, memberName_, filePath_, connId_, agentConnId_)] = let fileInv = FileInvitation {fileName, fileSize, fileConnReq} @@ -1510,22 +1712,36 @@ getRcvFileTransfer_ db userId fileId = acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ConnId -> FilePath -> m () acceptRcvFileTransfer st userId fileId agentConnId filePath = liftIO . withTransaction st $ \db -> do - DB.execute db "UPDATE files SET file_path = ? WHERE user_id = ? AND file_id = ?" (filePath, userId, fileId) - DB.execute db "UPDATE rcv_files SET file_status = ? WHERE file_id = ?" (FSAccepted, fileId) - - DB.execute db "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id) VALUES (?, ?, ?, ?, ?)" (agentConnId, ConnJoined, ConnRcvFile, fileId, userId) + currentTs <- getCurrentTime + DB.execute + db + "UPDATE files SET file_path = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" + (filePath, currentTs, userId, fileId) + DB.execute + db + "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" + (FSAccepted, currentTs, fileId) + DB.execute + db + "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (agentConnId, ConnJoined, ConnRcvFile, fileId, userId, currentTs, currentTs) updateRcvFileStatus :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> FileStatus -> m () updateRcvFileStatus st RcvFileTransfer {fileId} status = - liftIO . withTransaction st $ \db -> - DB.execute db "UPDATE rcv_files SET file_status = ? WHERE file_id = ?" (status, fileId) + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" (status, currentTs, fileId) createRcvFileChunk :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> Integer -> AgentMsgId -> m RcvChunkStatus createRcvFileChunk st RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, chunkSize} chunkNo msgId = liftIO . withTransaction st $ \db -> do status <- getLastChunkNo db - unless (status == RcvChunkError) $ - DB.execute db "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id) VALUES (?, ?, ?)" (fileId, chunkNo, msgId) + unless (status == RcvChunkError) $ do + currentTs <- getCurrentTime + DB.execute + db + "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, chunkNo, msgId, currentTs, currentTs) pure status where getLastChunkNo db = do @@ -1551,21 +1767,28 @@ createRcvFileChunk st RcvFileTransfer {fileId, fileInvitation = FileInvitation { updatedRcvFileChunkStored :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> Integer -> m () updatedRcvFileChunkStored st RcvFileTransfer {fileId} chunkNo = - liftIO . withTransaction st $ \db -> + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime DB.execute db [sql| UPDATE rcv_file_chunks - SET chunk_stored = 1 + SET chunk_stored = 1, updated_at = ? WHERE file_id = ? AND chunk_number = ? |] - (fileId, chunkNo) + (currentTs, fileId, chunkNo) deleteRcvFileChunks :: MonadUnliftIO m => SQLiteStore -> RcvFileTransfer -> m () deleteRcvFileChunks st RcvFileTransfer {fileId} = liftIO . withTransaction st $ \db -> DB.execute db "DELETE FROM rcv_file_chunks WHERE file_id = ?" (Only fileId) +updateFileTransferChatItemId :: MonadUnliftIO m => SQLiteStore -> FileTransferId -> ChatItemId -> m () +updateFileTransferChatItemId st fileId ciId = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ?" (ciId, currentTs, fileId) + getFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m FileTransfer getFileTransfer st userId fileId = liftIOEither . withTransaction st $ \db -> @@ -1616,108 +1839,739 @@ getSndFileTransfers_ db userId fileId = |] (userId, fileId) where - sndFileTransfers :: [(FileStatus, String, Integer, Integer, FilePath, Int64, ConnId, Maybe ContactName, Maybe ContactName)] -> Either StoreError [SndFileTransfer] + sndFileTransfers :: [(FileStatus, String, Integer, Integer, FilePath, Int64, AgentConnId, Maybe ContactName, Maybe ContactName)] -> Either StoreError [SndFileTransfer] sndFileTransfers [] = Left $ SESndFileNotFound fileId sndFileTransfers fts = mapM sndFileTransfer fts sndFileTransfer (fileStatus, fileName, fileSize, chunkSize, filePath, connId, agentConnId, contactName_, memberName_) = case contactName_ <|> memberName_ of - Just recipientDisplayName -> Right SndFileTransfer {..} + Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId} Nothing -> Left $ SESndFileInvalid fileId createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> m MessageId createNewMessage st newMsg = - liftIO . withTransaction st $ \db -> - createNewMessage_ db newMsg + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + createNewMessage_ db newMsg currentTs createSndMsgDelivery :: MonadUnliftIO m => SQLiteStore -> SndMsgDelivery -> MessageId -> m () createSndMsgDelivery st sndMsgDelivery messageId = liftIO . withTransaction st $ \db -> do - msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId - createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent + currentTs <- getCurrentTime + msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId currentTs + createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs -createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m () +createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m MessageId createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery = liftIO . withTransaction st $ \db -> do - messageId <- createNewMessage_ db newMsg - msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery messageId - createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent + currentTs <- getCurrentTime + messageId <- createNewMessage_ db newMsg currentTs + msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery messageId currentTs + createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs + pure messageId createSndMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> m () createSndMsgDeliveryEvent st connId agentMsgId sndMsgDeliveryStatus = liftIOEither . withTransaction st $ \db -> runExceptT $ do msgDeliveryId <- ExceptT $ getMsgDeliveryId_ db connId agentMsgId - liftIO $ createMsgDeliveryEvent_ db msgDeliveryId sndMsgDeliveryStatus + liftIO $ do + currentTs <- getCurrentTime + createMsgDeliveryEvent_ db msgDeliveryId sndMsgDeliveryStatus currentTs createRcvMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDRcv -> m () createRcvMsgDeliveryEvent st connId agentMsgId rcvMsgDeliveryStatus = liftIOEither . withTransaction st $ \db -> runExceptT $ do msgDeliveryId <- ExceptT $ getMsgDeliveryId_ db connId agentMsgId - liftIO $ createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus + liftIO $ do + currentTs <- getCurrentTime + createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus currentTs -createNewMessage_ :: DB.Connection -> NewMessage -> IO MessageId -createNewMessage_ db NewMessage {direction, chatMsgEventType, msgBody} = do - createdAt <- getCurrentTime +createNewMessage_ :: DB.Connection -> NewMessage -> UTCTime -> IO MessageId +createNewMessage_ db NewMessage {direction, cmEventTag, msgBody} createdAt = do DB.execute db [sql| INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at) VALUES (?,?,?,?); + (msg_sent, chat_msg_event, msg_body, created_at, updated_at) + VALUES (?,?,?,?,?) |] - (direction, chatMsgEventType, msgBody, createdAt) + (direction, cmEventTag, msgBody, createdAt, createdAt) insertedRowId db -createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> IO Int64 -createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId = do - chatTs <- getCurrentTime +createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> UTCTime -> IO Int64 +createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId createdAt = do DB.execute db [sql| INSERT INTO msg_deliveries - (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts) - VALUES (?,?,?,NULL,?); + (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at) + VALUES (?,?,?,NULL,?,?,?) |] - (messageId, connId, agentMsgId, chatTs) + (messageId, connId, agentMsgId, createdAt, createdAt, createdAt) insertedRowId db -createRcvMsgDelivery_ :: DB.Connection -> RcvMsgDelivery -> MessageId -> IO Int64 -createRcvMsgDelivery_ db RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} messageId = do +createRcvMsgDelivery_ :: DB.Connection -> RcvMsgDelivery -> MessageId -> UTCTime -> IO Int64 +createRcvMsgDelivery_ db RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} messageId createdAt = do DB.execute db [sql| INSERT INTO msg_deliveries - (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts) - VALUES (?,?,?,?,?); + (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) |] - (messageId, connId, agentMsgId, msgMetaJson agentMsgMeta, snd $ broker agentMsgMeta) + (messageId, connId, agentMsgId, msgMetaJson agentMsgMeta, snd $ broker agentMsgMeta, createdAt, createdAt) insertedRowId db -createMsgDeliveryEvent_ :: DB.Connection -> Int64 -> MsgDeliveryStatus d -> IO () -createMsgDeliveryEvent_ db msgDeliveryId msgDeliveryStatus = do - createdAt <- getCurrentTime +createMsgDeliveryEvent_ :: DB.Connection -> Int64 -> MsgDeliveryStatus d -> UTCTime -> IO () +createMsgDeliveryEvent_ db msgDeliveryId msgDeliveryStatus createdAt = do DB.execute db [sql| INSERT INTO msg_delivery_events - (msg_delivery_id, delivery_status, created_at) VALUES (?,?,?); + (msg_delivery_id, delivery_status, created_at, updated_at) + VALUES (?,?,?,?) |] - (msgDeliveryId, msgDeliveryStatus, createdAt) + (msgDeliveryId, msgDeliveryStatus, createdAt, createdAt) getMsgDeliveryId_ :: DB.Connection -> Int64 -> AgentMsgId -> IO (Either StoreError Int64) getMsgDeliveryId_ db connId agentMsgId = - toMsgDeliveryId - <$> DB.query + firstRow fromOnly (SENoMsgDelivery connId agentMsgId) $ + DB.query db [sql| SELECT msg_delivery_id FROM msg_deliveries m - WHERE m.connection_id = ? AND m.agent_msg_id == ? - LIMIT 1; + WHERE m.connection_id = ? AND m.agent_msg_id = ? + LIMIT 1 |] (connId, agentMsgId) + +createPendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> Maybe Int64 -> m () +createPendingGroupMessage st groupMemberId messageId introId_ = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO pending_group_messages + (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) + |] + (groupMemberId, messageId, introId_, currentTs, currentTs) + +getPendingGroupMessages :: MonadUnliftIO m => SQLiteStore -> Int64 -> m [PendingGroupMessage] +getPendingGroupMessages st groupMemberId = + liftIO . withTransaction st $ \db -> + map pendingGroupMessage + <$> DB.query + db + [sql| + SELECT pgm.message_id, m.chat_msg_event, m.msg_body, pgm.group_member_intro_id + FROM pending_group_messages pgm + JOIN messages m USING (message_id) + WHERE pgm.group_member_id = ? + ORDER BY pgm.message_id ASC + |] + (Only groupMemberId) where - toMsgDeliveryId :: [Only Int64] -> Either StoreError Int64 - toMsgDeliveryId [Only msgDeliveryId] = Right msgDeliveryId - toMsgDeliveryId _ = Left $ SENoMsgDelivery connId agentMsgId + pendingGroupMessage (msgId, cmEventTag, msgBody, introId_) = + PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} + +deletePendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> m () +deletePendingGroupMessage st groupMemberId messageId = + liftIO . withTransaction st $ \db -> + DB.execute db "DELETE FROM pending_group_messages WHERE group_member_id = ? AND message_id = ?" (groupMemberId, messageId) + +createNewChatItem :: (MonadUnliftIO m, MsgDirectionI d) => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId +createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, itemTs, itemContent, itemText, itemStatus, createdAt} = + liftIO . withTransaction st $ \db -> do + let (contactId_, groupId_, groupMemberId_) = ids + DB.execute + db + [sql| + INSERT INTO chat_items ( + user_id, contact_id, group_id, group_member_id, created_by_msg_id, + item_sent, item_ts, item_content, item_text, item_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, contactId_, groupId_, groupMemberId_, createdByMsgId) + :. (itemSent, itemTs, itemContent, itemText, itemStatus, createdAt, createdAt) + ) + ciId <- insertedRowId db + case createdByMsgId of + Nothing -> pure () + Just msgId -> + DB.execute + db + "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" + (ciId, msgId, createdAt, createdAt) + pure ciId + where + ids :: (Maybe Int64, Maybe Int64, Maybe Int64) + ids = case chatDirection of + CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing) + CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) + CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) + CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId) + +getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChat] +getChatPreviews st user = + liftIO . withTransaction st $ \db -> do + directChats <- getDirectChatPreviews_ db user + groupChats <- getGroupChatPreviews_ db user + cReqChats <- getContactRequestChatPreviews_ db user + pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats) + where + ts :: AChat -> UTCTime + ts (AChat _ (Chat _ (ci : _) _)) = chatItemTs ci + ts (AChat _ (Chat (DirectChat Contact {createdAt}) [] _)) = createdAt + ts (AChat _ (Chat (GroupChat GroupInfo {createdAt}) [] _)) = createdAt + ts (AChat _ (Chat (ContactRequest UserContactRequest {createdAt}) [] _)) = createdAt + +chatItemTs :: CChatItem d -> UTCTime +chatItemTs (CChatItem _ (ChatItem _ CIMeta {itemTs} _)) = itemTs + +getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] +getDirectChatPreviews_ db User {userId} = do + tz <- getCurrentTimeZone + map (toDirectChatPreview tz) + <$> DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, + -- ChatStats + COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + LEFT JOIN ( + SELECT contact_id, MAX(chat_item_id) AS MaxId + FROM chat_items + WHERE item_deleted != 1 + GROUP BY contact_id + ) MaxIds ON MaxIds.contact_id = ct.contact_id + LEFT JOIN chat_items ci ON ci.contact_id = MaxIds.contact_id + AND ci.chat_item_id = MaxIds.MaxId + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? + AND c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + LIMIT 1 + ) + ) + ORDER BY ci.item_ts DESC + |] + (CISRcvNew, userId, ConnReady, ConnSndReady) + where + toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow -> AChat + toDirectChatPreview tz (contactRow :. connRow :. statsRow :. ciRow_) = + let contact = toContact $ contactRow :. connRow + ci_ = toDirectChatItemList tz ciRow_ + stats = toChatStats statsRow + in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats + +getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] +getGroupChatPreviews_ db User {userId, userContactId} = do + tz <- getCurrentTimeZone + map (toGroupChatPreview tz) + <$> DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + pu.display_name, pu.full_name, + -- ChatStats + COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + -- Maybe GroupMember - sender + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, + p.display_name, p.full_name + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + LEFT JOIN ( + SELECT group_id, MAX(chat_item_id) AS MaxId + FROM chat_items + WHERE item_deleted != 1 + GROUP BY group_id + ) MaxIds ON MaxIds.group_id = g.group_id + LEFT JOIN chat_items ci ON ci.group_id = MaxIds.group_id + AND ci.chat_item_id = MaxIds.MaxId + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + WHERE g.user_id = ? AND mu.contact_id = ? + ORDER BY ci.item_ts DESC + |] + (CISRcvNew, userId, userContactId) + where + toGroupChatPreview :: TimeZone -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat + toGroupChatPreview tz (groupInfoRow :. statsRow :. ciRow_) = + let groupInfo = toGroupInfo userContactId groupInfoRow + ci_ = toGroupChatItemList tz userContactId ciRow_ + stats = toChatStats statsRow + in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats + +getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] +getContactRequestChatPreviews_ db User {userId} = + map toContactRequestChatPreview + <$> DB.query + db + [sql| + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + |] + (Only userId) + where + toContactRequestChatPreview :: ContactRequestRow -> AChat + toContactRequestChatPreview cReqRow = + let cReq = toContactRequest cReqRow + stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} + in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + +getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTDirect) +getDirectChat st user contactId pagination = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + case pagination of + CPLast count -> getDirectChatLast_ db user contactId count + CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count + CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count + +getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatLast_ db User {userId} contactId count = do + contact <- ExceptT $ getContact_ db userId contactId + stats <- liftIO $ getDirectChatStats_ db userId contactId + chatItems <- ExceptT getDirectChatItemsLast_ + pure $ Chat (DirectChat contact) (reverse chatItems) stats + where + getDirectChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTDirect]) + getDirectChatItemsLast_ = do + tz <- getCurrentTimeZone + mapM (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ? + ORDER BY ci.chat_item_id DESC + LIMIT ? + |] + (userId, contactId, count) + +getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do + contact <- ExceptT $ getContact_ db userId contactId + stats <- liftIO $ getDirectChatStats_ db userId contactId + chatItems <- ExceptT getDirectChatItemsAfter_ + pure $ Chat (DirectChat contact) chatItems stats + where + getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) + getDirectChatItemsAfter_ = do + tz <- getCurrentTimeZone + mapM (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ? AND ci.chat_item_id > ? + ORDER BY ci.chat_item_id ASC + LIMIT ? + |] + (userId, contactId, afterChatItemId, count) + +getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do + contact <- ExceptT $ getContact_ db userId contactId + stats <- liftIO $ getDirectChatStats_ db userId contactId + chatItems <- ExceptT getDirectChatItemsBefore_ + pure $ Chat (DirectChat contact) (reverse chatItems) stats + where + getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) + getDirectChatItemsBefore_ = do + tz <- getCurrentTimeZone + mapM (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ? AND ci.chat_item_id < ? + ORDER BY ci.chat_item_id DESC + LIMIT ? + |] + (userId, contactId, beforeChatItemId, count) + +getDirectChatStats_ :: DB.Connection -> UserId -> Int64 -> IO ChatStats +getDirectChatStats_ db userId contactId = + toChatStats' + <$> DB.query + db + [sql| + SELECT COUNT(1), MIN(chat_item_id) + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + GROUP BY contact_id + |] + (userId, contactId, CISRcvNew) + where + toChatStats' :: [ChatStatsRow] -> ChatStats + toChatStats' [statsRow] = toChatStats statsRow + toChatStats' _ = ChatStats {unreadCount = 0, minUnreadItemId = 0} + +getContactIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 +getContactIdByName st userId cName = + liftIOEither . withTransaction st $ \db -> getContactIdByName_ db userId cName + +getContactIdByName_ :: DB.Connection -> UserId -> ContactName -> IO (Either StoreError Int64) +getContactIdByName_ db userId cName = + firstRow fromOnly (SEContactNotFoundByName cName) $ + DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName) + +getContact :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m Contact +getContact st userId contactId = + liftIOEither . withTransaction st $ \db -> getContact_ db userId contactId + +getContact_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError Contact) +getContact_ db userId contactId = + join + <$> firstRow + toContactOrError + (SEContactNotFound contactId) + ( DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? AND (c.conn_status = ? OR c.conn_status = ?) + ORDER BY c.connection_id DESC + LIMIT 1 + |] + (userId, contactId, ConnReady, ConnSndReady) + ) + +getGroupChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTGroup) +getGroupChat st user groupId pagination = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + case pagination of + CPLast count -> getGroupChatLast_ db user groupId count + CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count + CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count + +getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ db user@User {userId, userContactId} groupId count = do + groupInfo <- ExceptT $ getGroupInfo_ db user groupId + stats <- liftIO $ getGroupChatStats_ db userId groupId + chatItems <- ExceptT getGroupChatItemsLast_ + pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats + where + getGroupChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTGroup]) + getGroupChatItemsLast_ = do + tz <- getCurrentTimeZone + mapM (toGroupChatItem tz userContactId) + <$> DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, + p.display_name, p.full_name + FROM chat_items ci + LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + WHERE ci.user_id = ? AND ci.group_id = ? + ORDER BY ci.chat_item_id DESC + LIMIT ? + |] + (userId, groupId, count) + +getGroupChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId count = do + groupInfo <- ExceptT $ getGroupInfo_ db user groupId + stats <- liftIO $ getGroupChatStats_ db userId groupId + chatItems <- ExceptT getGroupChatItemsAfter_ + pure $ Chat (GroupChat groupInfo) chatItems stats + where + getGroupChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTGroup]) + getGroupChatItemsAfter_ = do + tz <- getCurrentTimeZone + mapM (toGroupChatItem tz userContactId) + <$> DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, + p.display_name, p.full_name + FROM chat_items ci + LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + WHERE ci.user_id = ? AND ci.group_id = ? AND ci.chat_item_id > ? + ORDER BY ci.chat_item_id ASC + LIMIT ? + |] + (userId, groupId, afterChatItemId, count) + +getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemId count = do + groupInfo <- ExceptT $ getGroupInfo_ db user groupId + stats <- liftIO $ getGroupChatStats_ db userId groupId + chatItems <- ExceptT getGroupChatItemsBefore_ + pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats + where + getGroupChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTGroup]) + getGroupChatItemsBefore_ = do + tz <- getCurrentTimeZone + mapM (toGroupChatItem tz userContactId) + <$> DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, + p.display_name, p.full_name + FROM chat_items ci + LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + WHERE ci.user_id = ? AND ci.group_id = ? AND ci.chat_item_id < ? + ORDER BY ci.chat_item_id DESC + LIMIT ? + |] + (userId, groupId, beforeChatItemId, count) + +getGroupChatStats_ :: DB.Connection -> UserId -> Int64 -> IO ChatStats +getGroupChatStats_ db userId groupId = + toChatStats' + <$> DB.query + db + [sql| + SELECT COUNT(1), MIN(chat_item_id) + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + GROUP BY group_id + |] + (userId, groupId, CISRcvNew) + where + toChatStats' :: [ChatStatsRow] -> ChatStats + toChatStats' [statsRow] = toChatStats statsRow + toChatStats' _ = ChatStats {unreadCount = 0, minUnreadItemId = 0} + +getGroupInfo :: StoreMonad m => SQLiteStore -> User -> Int64 -> m GroupInfo +getGroupInfo st user groupId = + liftIOEither . withTransaction st $ \db -> + getGroupInfo_ db user groupId + +getGroupInfo_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError GroupInfo) +getGroupInfo_ db User {userId, userContactId} groupId = + firstRow (toGroupInfo userContactId) (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + pu.display_name, pu.full_name + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (groupId, userId, userContactId) + +getGroupIdByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Int64 +getGroupIdByName st user gName = + liftIOEither . withTransaction st $ \db -> getGroupIdByName_ db user gName + +getGroupIdByName_ :: DB.Connection -> User -> GroupName -> IO (Either StoreError Int64) +getGroupIdByName_ db User {userId} gName = + firstRow fromOnly (SEGroupNotFoundByName gName) $ + DB.query db "SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ?" (userId, gName) + +getChatItemIdByAgentMsgId :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> m (Maybe ChatItemId) +getChatItemIdByAgentMsgId st connId msgId = + liftIO . withTransaction st $ \db -> + join . listToMaybe . map fromOnly + <$> DB.query + db + [sql| + SELECT chat_item_id + FROM chat_item_messages + WHERE message_id = ( + SELECT message_id + FROM msg_deliveries + WHERE connection_id = ? AND agent_msg_id = ? + LIMIT 1 + ) + |] + (connId, msgId) + +updateDirectChatItem :: (StoreMonad m, MsgDirectionI d) => SQLiteStore -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) +updateDirectChatItem st itemId itemStatus = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + ci <- ExceptT $ getDirectChatItem_ db itemId + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE chat_item_id = ?" (itemStatus, currentTs, itemId) + pure ci {meta = (meta ci) {itemStatus}} + +getDirectChatItem_ :: forall d. MsgDirectionI d => DB.Connection -> ChatItemId -> IO (Either StoreError (ChatItem 'CTDirect d)) +getDirectChatItem_ db itemId = do + tz <- getCurrentTimeZone + join + <$> firstRow + (correctDir <=< toDirectChatItem tz) + (SEChatItemNotFound itemId) + ( DB.query + db + [sql| + SELECT + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at + FROM chat_items ci + WHERE ci.chat_item_id = ? + |] + (Only itemId) + ) + where + correctDir :: CChatItem c -> Either StoreError (ChatItem c d) + correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + +updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () +updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do + currentTs <- liftIO getCurrentTime + liftIO . withTransaction st $ \db -> + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE contact_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? + |] + (CISRcvRead, currentTs, contactId, fromItemId, toItemId, CISRcvNew) + +updateGroupChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () +updateGroupChatItemsRead st groupId (fromItemId, toItemId) = do + currentTs <- liftIO getCurrentTime + liftIO . withTransaction st $ \db -> + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE group_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? + |] + (CISRcvRead, currentTs, groupId, fromItemId, toItemId, CISRcvNew) + +type ChatStatsRow = (Int, ChatItemId) + +toChatStats :: ChatStatsRow -> ChatStats +toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadItemId} + +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, UTCTime) + +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe UTCTime) + +toDirectChatItem :: TimeZone -> ChatItemRow -> Either StoreError (CChatItem 'CTDirect) +toDirectChatItem tz (itemId, itemTs, itemContent, itemText, itemStatus, createdAt) = + case (itemContent, itemStatus) of + (ACIContent d@SMDSnd ciContent, ACIStatus d'@SMDSnd ciStatus) -> case testEquality d d' of + Just Refl -> Right $ CChatItem d (ChatItem CIDirectSnd (ciMeta ciStatus) ciContent) + _ -> badItem + (ACIContent d@SMDRcv ciContent, ACIStatus d'@SMDRcv ciStatus) -> case testEquality d d' of + Just Refl -> Right $ CChatItem d (ChatItem CIDirectRcv (ciMeta ciStatus) ciContent) + _ -> badItem + _ -> badItem + where + badItem = Left $ SEBadChatItem itemId + ciMeta :: CIStatus d -> CIMeta d + ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt + +toDirectChatItemList :: TimeZone -> MaybeChatItemRow -> [CChatItem 'CTDirect] +toDirectChatItemList tz (Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, Just createdAt) = + either (const []) (: []) $ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, itemStatus, createdAt) +toDirectChatItemList _ _ = [] + +type GroupChatItemRow = ChatItemRow :. MaybeGroupMemberRow + +type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow + +toGroupChatItem :: TimeZone -> Int64 -> GroupChatItemRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_) = do + let member_ = toMaybeGroupMember userContactId memberRow_ + case (itemContent, itemStatus, member_) of + (ACIContent d@SMDSnd ciContent, ACIStatus d'@SMDSnd ciStatus, Nothing) -> case testEquality d d' of + Just Refl -> Right $ CChatItem d (ChatItem CIGroupSnd (ciMeta ciStatus) ciContent) + _ -> badItem + (ACIContent d@SMDRcv ciContent, ACIStatus d'@SMDRcv ciStatus, Just member) -> case testEquality d d' of + Just Refl -> Right $ CChatItem d (ChatItem (CIGroupRcv member) (ciMeta ciStatus) ciContent) + _ -> badItem + _ -> badItem + where + badItem = Left $ SEBadChatItem itemId + ciMeta :: CIStatus d -> CIMeta d + ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt + +toGroupChatItemList :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] +toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, Just createdAt) :. memberRow_) = + either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_) +toGroupChatItemList _ _ _ = [] -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. @@ -1739,21 +2593,23 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate tryCreateName :: Int -> Int -> IO (Either StoreError a) tryCreateName _ 0 = pure $ Left SEDuplicateName tryCreateName ldnSuffix attempts = do + currentTs <- getCurrentTime let ldn = displayName <> (if ldnSuffix == 0 then "" else T.pack $ '_' : show ldnSuffix) - E.try (insertName ldn) >>= \case + E.try (insertName ldn currentTs) >>= \case Right () -> Right <$> action ldn Left e | DB.sqlError e == DB.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1) | otherwise -> E.throwIO e where - insertName ldn = + insertName ldn ts = DB.execute db [sql| INSERT INTO display_names - (local_display_name, ldn_base, ldn_suffix, user_id) VALUES (?, ?, ?, ?) + (local_display_name, ldn_base, ldn_suffix, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) |] - (ldn, displayName, ldnSuffix, userId) + (ldn, displayName, ldnSuffix, userId, ts, ts) createWithRandomId :: forall a. TVar ChaChaDRG -> (ByteString -> IO a) -> IO (Either StoreError a) createWithRandomId = createWithRandomBytes 12 @@ -1769,31 +2625,41 @@ createWithRandomBytes size gVar create = tryCreate 3 Right x -> pure $ Right x Left e | DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1) - | otherwise -> pure . Left . SEInternal $ bshow e + | otherwise -> pure . Left . SEInternalError $ show e randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) +-- These error type constructors must be added to mobile apps data StoreError = SEDuplicateName - | SEContactNotFound ContactName - | SEContactNotReady ContactName + | SEContactNotFound {contactId :: Int64} + | SEContactNotFoundByName {contactName :: ContactName} + | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink | SEUserContactLinkNotFound - | SEContactRequestNotFound ContactName - | SEGroupNotFound GroupName + | SEContactRequestNotFound {contactRequestId :: Int64} + | SEContactRequestNotFoundByName {contactName :: ContactName} + | SEGroupNotFound {groupId :: Int64} + | SEGroupNotFoundByName {groupName :: GroupName} | SEGroupWithoutUser | SEDuplicateGroupMember | SEGroupAlreadyJoined | SEGroupInvitationNotFound - | SESndFileNotFound Int64 - | SESndFileInvalid Int64 - | SERcvFileNotFound Int64 - | SEFileNotFound Int64 - | SERcvFileInvalid Int64 - | SEConnectionNotFound ConnId + | SESndFileNotFound {fileId :: FileTransferId} + | SESndFileInvalid {fileId :: FileTransferId} + | SERcvFileNotFound {fileId :: FileTransferId} + | SEFileNotFound {fileId :: FileTransferId} + | SERcvFileInvalid {fileId :: FileTransferId} + | SEConnectionNotFound {agentConnId :: AgentConnId} | SEIntroNotFound | SEUniqueID - | SEInternal ByteString - | SENoMsgDelivery Int64 AgentMsgId - deriving (Show, Exception) + | SEInternalError {message :: String} + | SENoMsgDelivery {connId :: Int64, agentMsgId :: AgentMsgId} + | SEBadChatItem {itemId :: ChatItemId} + | SEChatItemNotFound {itemId :: ChatItemId} + deriving (Show, Exception, Generic) + +instance ToJSON StoreError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index f7a3a80acf..aaed7a4f7f 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -6,6 +6,7 @@ module Simplex.Chat.Styled StyledFormat (..), styleMarkdown, styleMarkdownText, + unStyle, sLength, sShow, ) @@ -20,6 +21,7 @@ import Simplex.Chat.Markdown import System.Console.ANSI.Types data StyledString = Styled [SGR] String | StyledString :<>: StyledString + deriving (Show) instance Semigroup StyledString where (<>) = (:<>:) @@ -69,6 +71,10 @@ sgr = \case Snippet -> [] NoFormat -> [] +unStyle :: StyledString -> String +unStyle (Styled _ s) = s +unStyle (s1 :<>: s2) = unStyle s1 <> unStyle s2 + sLength :: StyledString -> Int sLength (Styled _ s) = length s sLength (s1 :<>: s2) = sLength s1 + sLength s2 diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 24d613f486..d8e14b3422 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,176 +1,45 @@ -{-# LANGUAGE GADTs #-} -{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE RankNTypes #-} -{-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Terminal where -import Control.Monad.Catch (MonadMask) -import Control.Monad.IO.Class (MonadIO) -import Simplex.Chat.Styled -import Simplex.Chat.Types -import System.Console.ANSI.Types -import System.Terminal -import System.Terminal.Internal (LocalTerminal, Terminal, VirtualTerminal) -import UnliftIO.STM +import Control.Logger.Simple +import Control.Monad.Except +import Control.Monad.Reader +import Simplex.Chat +import Simplex.Chat.Controller +import Simplex.Chat.Help (chatWelcome) +import Simplex.Chat.Options +import Simplex.Chat.Store +import Simplex.Chat.Terminal.Input +import Simplex.Chat.Terminal.Notification +import Simplex.Chat.Terminal.Output +import Simplex.Chat.Types (User) +import Simplex.Messaging.Util (raceAny_) +import UnliftIO (async, waitEither_) -data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName - deriving (Eq) - -data ChatTerminal = ChatTerminal - { activeTo :: TVar ActiveTo, - termDevice :: TerminalDevice, - termState :: TVar TerminalState, - termSize :: Size, - nextMessageRow :: TVar Int, - termLock :: TMVar () - } - -data TerminalState = TerminalState - { inputPrompt :: String, - inputString :: String, - inputPosition :: Int, - previousInput :: String - } - -class Terminal t => WithTerminal t where - withTerm :: (MonadIO m, MonadMask m) => t -> (t -> m a) -> m a - -data TerminalDevice = forall t. WithTerminal t => TerminalDevice t - -instance WithTerminal LocalTerminal where - withTerm _ = withTerminal - -instance WithTerminal VirtualTerminal where - withTerm t = ($ t) - -withChatTerm :: (MonadIO m, MonadMask m) => ChatTerminal -> (forall t. WithTerminal t => TerminalT t m a) -> m a -withChatTerm ChatTerminal {termDevice = TerminalDevice t} action = withTerm t $ runTerminalT action - -newChatTerminal :: WithTerminal t => t -> IO ChatTerminal -newChatTerminal t = do - activeTo <- newTVarIO ActiveNone - termSize <- withTerm t . runTerminalT $ getWindowSize - let lastRow = height termSize - 1 - termState <- newTVarIO newTermState - termLock <- newTMVarIO () - nextMessageRow <- newTVarIO lastRow - -- threadDelay 500000 -- this delay is the same as timeout in getTerminalSize - return ChatTerminal {activeTo, termDevice = TerminalDevice t, termState, termSize, nextMessageRow, termLock} - -newTermState :: TerminalState -newTermState = - TerminalState - { inputString = "", - inputPosition = 0, - inputPrompt = "> ", - previousInput = "" - } - -withTermLock :: MonadTerminal m => ChatTerminal -> m () -> m () -withTermLock ChatTerminal {termLock} action = do - _ <- atomically $ takeTMVar termLock - action - atomically $ putTMVar termLock () - -printToTerminal :: ChatTerminal -> [StyledString] -> IO () -printToTerminal ct s = - withChatTerm ct $ - withTermLock ct $ do - printMessage ct s - updateInput ct - -updateInput :: forall m. MonadTerminal m => ChatTerminal -> m () -updateInput ChatTerminal {termSize = Size {height, width}, termState, nextMessageRow} = do - hideCursor - ts <- readTVarIO termState - nmr <- readTVarIO nextMessageRow - let ih = inputHeight ts - iStart = height - ih - prompt = inputPrompt ts - Position {row, col} = positionRowColumn width $ length prompt + inputPosition ts - if nmr >= iStart - then atomically $ writeTVar nextMessageRow iStart - else clearLines nmr iStart - setCursorPosition $ Position {row = max nmr iStart, col = 0} - putString $ prompt <> inputString ts <> " " - eraseInLine EraseForward - setCursorPosition $ Position {row = iStart + row, col} - showCursor - flush +simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () +simplexChat cfg@ChatConfig {dbPoolSize, yesToMigrations} opts t + | logging opts = do + setLogLevel LogInfo -- LogError + withGlobalLogging logCfg initRun + | otherwise = initRun where - clearLines :: Int -> Int -> m () - clearLines from till - | from >= till = return () - | otherwise = do - setCursorPosition $ Position {row = from, col = 0} - eraseInLine EraseForward - clearLines (from + 1) till - inputHeight :: TerminalState -> Int - inputHeight ts = length (inputPrompt ts <> inputString ts) `div` width + 1 - positionRowColumn :: Int -> Int -> Position - positionRowColumn wid pos = - let row = pos `div` wid - col = pos - row * wid - in Position {row, col} + initRun = do + sendNotification' <- initializeNotifications + let f = chatStoreFile $ dbFilePrefix opts + st <- createStore f dbPoolSize yesToMigrations + u <- getCreateActiveUser st + ct <- newChatTerminal t + cc <- newChatController st (Just u) cfg opts sendNotification' + runSimplexChat u ct cc -printMessage :: forall m. MonadTerminal m => ChatTerminal -> [StyledString] -> m () -printMessage ChatTerminal {termSize = Size {height, width}, nextMessageRow} msg = do - nmr <- readTVarIO nextMessageRow - setCursorPosition $ Position {row = nmr, col = 0} - mapM_ printStyled msg - flush - let lc = sum $ map lineCount msg - atomically . writeTVar nextMessageRow $ min (height - 1) (nmr + lc) - where - lineCount :: StyledString -> Int - lineCount s = sLength s `div` width + 1 - printStyled :: StyledString -> m () - printStyled s = do - putStyled s - eraseInLine EraseForward - putLn +runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () +runSimplexChat u ct cc = do + when (firstTime cc) . printToTerminal ct $ chatWelcome u + a1 <- async $ runChatTerminal ct cc + a2 <- runReaderT (startChatController u) cc + waitEither_ a1 a2 --- Currently it is assumed that the message does not have internal line breaks. --- Previous implementation "kind of" supported them, --- but it was not determining the number of printed lines correctly --- because of accounting for control sequences in length -putStyled :: MonadTerminal m => StyledString -> m () -putStyled (s1 :<>: s2) = putStyled s1 >> putStyled s2 -putStyled (Styled [] s) = putString s -putStyled (Styled sgr s) = setSGR sgr >> putString s >> resetAttributes - -setSGR :: MonadTerminal m => [SGR] -> m () -setSGR = mapM_ $ \case - Reset -> resetAttributes - SetConsoleIntensity BoldIntensity -> setAttribute bold - SetConsoleIntensity _ -> resetAttribute bold - SetItalicized True -> setAttribute italic - SetItalicized _ -> resetAttribute italic - SetUnderlining NoUnderline -> resetAttribute underlined - SetUnderlining _ -> setAttribute underlined - SetSwapForegroundBackground True -> setAttribute inverted - SetSwapForegroundBackground _ -> resetAttribute inverted - SetColor l i c -> setAttribute . layer l . intensity i $ color c - SetBlinkSpeed _ -> pure () - SetVisible _ -> pure () - SetRGBColor _ _ -> pure () - SetPaletteColor _ _ -> pure () - SetDefaultColor _ -> pure () - where - layer = \case - Foreground -> foreground - Background -> background - intensity = \case - Dull -> id - Vivid -> bright - color = \case - Black -> black - Red -> red - Green -> green - Yellow -> yellow - Blue -> blue - Magenta -> magenta - Cyan -> cyan - White -> white +runChatTerminal :: ChatTerminal -> ChatController -> IO () +runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Input.hs b/src/Simplex/Chat/Terminal/Input.hs similarity index 82% rename from src/Simplex/Chat/Input.hs rename to src/Simplex/Chat/Terminal/Input.hs index 5369c3db9a..e401b4b8c0 100644 --- a/src/Simplex/Chat/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -1,15 +1,19 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE ScopedTypeVariables #-} -module Simplex.Chat.Input where +module Simplex.Chat.Terminal.Input where import Control.Monad.IO.Unlift import Control.Monad.Reader import Data.List (dropWhileEnd) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat import Simplex.Chat.Controller -import Simplex.Chat.Terminal +import Simplex.Chat.Terminal.Output +import Simplex.Chat.View import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM @@ -21,16 +25,20 @@ getKey = Right (KeyEvent key ms) -> pure (key, ms) _ -> getKey -runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => m () -runTerminalInput = do - ChatController {inputQ, chatTerminal = ct} <- ask - liftIO $ - withChatTerm ct $ do - updateInput ct - receiveFromTTY inputQ ct +runInputLoop :: ChatTerminal -> ChatController -> IO () +runInputLoop ct cc = forever $ do + s <- atomically . readTBQueue $ inputQ cc + r <- runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc + let testV = testView $ config cc + printToTerminal ct $ responseToView s testV r -receiveFromTTY :: MonadTerminal m => TBQueue InputEvent -> ChatTerminal -> m () -receiveFromTTY inputQ ct@ChatTerminal {activeTo, termSize, termState} = +runTerminalInput :: ChatTerminal -> ChatController -> IO () +runTerminalInput ct cc = withChatTerm ct $ do + updateInput ct + receiveFromTTY cc ct + +receiveFromTTY :: MonadTerminal m => ChatController -> ChatTerminal -> m () +receiveFromTTY ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, termState} = forever $ getKey >>= processKey >> withTermLock ct (updateInput ct) where processKey :: MonadTerminal m => (Key, Modifiers) -> m () @@ -45,7 +53,7 @@ receiveFromTTY inputQ ct@ChatTerminal {activeTo, termSize, termState} = ts <- readTVar termState let s = inputString ts writeTVar termState $ ts {inputString = "", inputPosition = 0, previousInput = s} - writeTBQueue inputQ $ InputCommand s + writeTBQueue inputQ s updateTermState :: ActiveTo -> Int -> (Key, Modifiers) -> TerminalState -> TerminalState updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p} = case key of diff --git a/src/Simplex/Chat/Notification.hs b/src/Simplex/Chat/Terminal/Notification.hs similarity index 96% rename from src/Simplex/Chat/Notification.hs rename to src/Simplex/Chat/Terminal/Notification.hs index 3f4883205e..b1a5fee3e6 100644 --- a/src/Simplex/Chat/Notification.hs +++ b/src/Simplex/Chat/Terminal/Notification.hs @@ -3,7 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -module Simplex.Chat.Notification (Notification (..), initializeNotifications) where +module Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) where import Control.Exception import Control.Monad (void) @@ -13,13 +13,12 @@ import qualified Data.Map as M import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T +import Simplex.Chat.Types import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory) import System.FilePath (combine) import System.Info (os) import System.Process (readCreateProcess, shell) -data Notification = Notification {title :: Text, text :: Text} - initializeNotifications :: IO (Notification -> IO ()) initializeNotifications = hideException <$> case os of diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs new file mode 100644 index 0000000000..79526e624f --- /dev/null +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -0,0 +1,180 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Chat.Terminal.Output where + +import Control.Monad.Catch (MonadMask) +import Control.Monad.IO.Unlift +import Control.Monad.Reader +import Simplex.Chat.Controller +import Simplex.Chat.Styled +import Simplex.Chat.View +import System.Console.ANSI.Types +import System.Terminal +import System.Terminal.Internal (LocalTerminal, Terminal, VirtualTerminal) +import UnliftIO.STM + +data ChatTerminal = ChatTerminal + { termDevice :: TerminalDevice, + termState :: TVar TerminalState, + termSize :: Size, + nextMessageRow :: TVar Int, + termLock :: TMVar () + } + +data TerminalState = TerminalState + { inputPrompt :: String, + inputString :: String, + inputPosition :: Int, + previousInput :: String + } + +class Terminal t => WithTerminal t where + withTerm :: (MonadIO m, MonadMask m) => t -> (t -> m a) -> m a + +data TerminalDevice = forall t. WithTerminal t => TerminalDevice t + +instance WithTerminal LocalTerminal where + withTerm _ = withTerminal + +instance WithTerminal VirtualTerminal where + withTerm t = ($ t) + +withChatTerm :: (MonadIO m, MonadMask m) => ChatTerminal -> (forall t. WithTerminal t => TerminalT t m a) -> m a +withChatTerm ChatTerminal {termDevice = TerminalDevice t} action = withTerm t $ runTerminalT action + +newChatTerminal :: WithTerminal t => t -> IO ChatTerminal +newChatTerminal t = do + termSize <- withTerm t . runTerminalT $ getWindowSize + let lastRow = height termSize - 1 + termState <- newTVarIO mkTermState + termLock <- newTMVarIO () + nextMessageRow <- newTVarIO lastRow + -- threadDelay 500000 -- this delay is the same as timeout in getTerminalSize + return ChatTerminal {termDevice = TerminalDevice t, termState, termSize, nextMessageRow, termLock} + +mkTermState :: TerminalState +mkTermState = + TerminalState + { inputString = "", + inputPosition = 0, + inputPrompt = "> ", + previousInput = "" + } + +withTermLock :: MonadTerminal m => ChatTerminal -> m () -> m () +withTermLock ChatTerminal {termLock} action = do + _ <- atomically $ takeTMVar termLock + action + atomically $ putTMVar termLock () + +runTerminalOutput :: ChatTerminal -> ChatController -> IO () +runTerminalOutput ct cc = do + let testV = testView $ config cc + forever $ + atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView "" testV . snd + +printToTerminal :: ChatTerminal -> [StyledString] -> IO () +printToTerminal ct s = + withChatTerm ct $ + withTermLock ct $ do + printMessage ct s + updateInput ct + +updateInput :: forall m. MonadTerminal m => ChatTerminal -> m () +updateInput ChatTerminal {termSize = Size {height, width}, termState, nextMessageRow} = do + hideCursor + ts <- readTVarIO termState + nmr <- readTVarIO nextMessageRow + let ih = inputHeight ts + iStart = height - ih + prompt = inputPrompt ts + Position {row, col} = positionRowColumn width $ length prompt + inputPosition ts + if nmr >= iStart + then atomically $ writeTVar nextMessageRow iStart + else clearLines nmr iStart + setCursorPosition $ Position {row = max nmr iStart, col = 0} + putString $ prompt <> inputString ts <> " " + eraseInLine EraseForward + setCursorPosition $ Position {row = iStart + row, col} + showCursor + flush + where + clearLines :: Int -> Int -> m () + clearLines from till + | from >= till = return () + | otherwise = do + setCursorPosition $ Position {row = from, col = 0} + eraseInLine EraseForward + clearLines (from + 1) till + inputHeight :: TerminalState -> Int + inputHeight ts = length (inputPrompt ts <> inputString ts) `div` width + 1 + positionRowColumn :: Int -> Int -> Position + positionRowColumn wid pos = + let row = pos `div` wid + col = pos - row * wid + in Position {row, col} + +printMessage :: forall m. MonadTerminal m => ChatTerminal -> [StyledString] -> m () +printMessage ChatTerminal {termSize = Size {height, width}, nextMessageRow} msg = do + nmr <- readTVarIO nextMessageRow + setCursorPosition $ Position {row = nmr, col = 0} + mapM_ printStyled msg + flush + let lc = sum $ map lineCount msg + atomically . writeTVar nextMessageRow $ min (height - 1) (nmr + lc) + where + lineCount :: StyledString -> Int + lineCount s = sLength s `div` width + 1 + printStyled :: StyledString -> m () + printStyled s = do + putStyled s + eraseInLine EraseForward + putLn + +-- Currently it is assumed that the message does not have internal line breaks. +-- Previous implementation "kind of" supported them, +-- but it was not determining the number of printed lines correctly +-- because of accounting for control sequences in length +putStyled :: MonadTerminal m => StyledString -> m () +putStyled (s1 :<>: s2) = putStyled s1 >> putStyled s2 +putStyled (Styled [] s) = putString s +putStyled (Styled sgr s) = setSGR sgr >> putString s >> resetAttributes + +setSGR :: MonadTerminal m => [SGR] -> m () +setSGR = mapM_ $ \case + Reset -> resetAttributes + SetConsoleIntensity BoldIntensity -> setAttribute bold + SetConsoleIntensity _ -> resetAttribute bold + SetItalicized True -> setAttribute italic + SetItalicized _ -> resetAttribute italic + SetUnderlining NoUnderline -> resetAttribute underlined + SetUnderlining _ -> setAttribute underlined + SetSwapForegroundBackground True -> setAttribute inverted + SetSwapForegroundBackground _ -> resetAttribute inverted + SetColor l i c -> setAttribute . layer l . intensity i $ color c + SetBlinkSpeed _ -> pure () + SetVisible _ -> pure () + SetRGBColor _ _ -> pure () + SetPaletteColor _ _ -> pure () + SetDefaultColor _ -> pure () + where + layer = \case + Foreground -> foreground + Background -> background + intensity = \case + Dull -> id + Vivid -> bright + color = \case + Black -> black + Red -> red + Green -> green + Yellow -> yellow + Blue -> blue + Magenta -> magenta + Cyan -> cyan + White -> white diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 386fa732b6..96f829f3ce 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1,7 +1,9 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} @@ -9,33 +11,31 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} +{-# LANGUAGE UndecidableInstances #-} module Simplex.Chat.Types where -import Data.Aeson (FromJSON, ToJSON, (.:), (.=)) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A -import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (decodeLatin1) import Data.Time.Clock (UTCTime) -import Data.Type.Equality -import Data.Typeable (Typeable) +import Data.Typeable import Database.SQLite.Simple (ResultError (..), SQLData (..)) import Database.SQLite.Simple.FromField (FieldParser, FromField (..), returnError) import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, MsgMeta (..), serializeMsgIntegrity) +import GHC.Generics (Generic) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) class IsContact a where @@ -60,6 +60,9 @@ data User = User profile :: Profile, activeUser :: Bool } + deriving (Show, Generic, FromJSON) + +instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions type UserId = Int64 @@ -68,15 +71,20 @@ data Contact = Contact localDisplayName :: ContactName, profile :: Profile, activeConn :: Connection, - viaGroup :: Maybe Int64 + viaGroup :: Maybe Int64, + createdAt :: UTCTime } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON Contact where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} contactConn :: Contact -> Connection contactConn = activeConn contactConnId :: Contact -> ConnId -contactConnId Contact {activeConn = Connection {agentConnId}} = agentConnId +contactConnId Contact {activeConn} = aConnId activeConn data UserContact = UserContact { userContactLinkId :: Int64, @@ -86,26 +94,41 @@ data UserContact = UserContact data UserContactRequest = UserContactRequest { contactRequestId :: Int64, - agentInvitationId :: InvitationId, + agentInvitationId :: AgentInvId, userContactLinkId :: Int64, - agentContactConnId :: ConnId, + agentContactConnId :: AgentConnId, -- connection id of user contact localDisplayName :: ContactName, - profileId :: Int64 + profileId :: Int64, + profile :: Profile, + createdAt :: UTCTime } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON UserContactRequest where + toEncoding = J.genericToEncoding J.defaultOptions type ContactName = Text type GroupName = Text -data Group = Group +data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} + deriving (Eq, Show, Generic) + +instance ToJSON Group where toEncoding = J.genericToEncoding J.defaultOptions + +data GroupInfo = GroupInfo { groupId :: Int64, localDisplayName :: GroupName, groupProfile :: GroupProfile, - members :: [GroupMember], - membership :: GroupMember + membership :: GroupMember, + createdAt :: UTCTime } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions + +groupName' :: GroupInfo -> GroupName +groupName' GroupInfo {localDisplayName = g} = g data Profile = Profile { displayName :: ContactName, @@ -164,9 +187,8 @@ memberInfo GroupMember {memberId, memberRole, memberProfile} = data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, - userMember :: GroupMember, connRequest :: ConnReqInvitation, - groupProfile :: GroupProfile + groupInfo :: GroupInfo } deriving (Eq, Show) @@ -183,15 +205,17 @@ data GroupMember = GroupMember memberContactId :: Maybe Int64, activeConn :: Maybe Connection } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupMember where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} memberConn :: GroupMember -> Maybe Connection memberConn = activeConn memberConnId :: GroupMember -> Maybe ConnId -memberConnId GroupMember {activeConn} = case activeConn of - Just Connection {agentConnId} -> Just agentConnId - Nothing -> Nothing +memberConnId GroupMember {activeConn} = aConnId <$> activeConn data NewGroupMember = NewGroupMember { memInfo :: MemberInfo, @@ -222,8 +246,15 @@ instance ToJSON MemberId where toJSON = strToJSON toEncoding = strToJEncoding -data InvitedBy = IBContact Int64 | IBUser | IBUnknown - deriving (Eq, Show) +data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown + deriving (Eq, Show, Generic) + +instance FromJSON InvitedBy where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" + +instance ToJSON InvitedBy where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy toInvitedBy userCtId (Just ctId) @@ -309,26 +340,30 @@ data GroupMemberCategory | GCPostMember -- member who joined after the user to whom the user was introduced (user receives x.grp.mem.new announcing these members and then x.grp.mem.fwd with invitation from these members) deriving (Eq, Show) -instance FromField GroupMemberCategory where fromField = fromTextField_ memberCategoryT +instance FromField GroupMemberCategory where fromField = fromTextField_ decodeText -instance ToField GroupMemberCategory where toField = toField . serializeMemberCategory +instance ToField GroupMemberCategory where toField = toField . encodeText -memberCategoryT :: Text -> Maybe GroupMemberCategory -memberCategoryT = \case - "user" -> Just GCUserMember - "invitee" -> Just GCInviteeMember - "host" -> Just GCHostMember - "pre" -> Just GCPreMember - "post" -> Just GCPostMember - _ -> Nothing +instance FromJSON GroupMemberCategory where parseJSON = textParseJSON "GroupMemberCategory" -serializeMemberCategory :: GroupMemberCategory -> Text -serializeMemberCategory = \case - GCUserMember -> "user" - GCInviteeMember -> "invitee" - GCHostMember -> "host" - GCPreMember -> "pre" - GCPostMember -> "post" +instance ToJSON GroupMemberCategory where + toJSON = J.String . encodeText + toEncoding = JE.text . encodeText + +instance TextEncoding GroupMemberCategory where + decodeText = \case + "user" -> Just GCUserMember + "invitee" -> Just GCInviteeMember + "host" -> Just GCHostMember + "pre" -> Just GCPreMember + "post" -> Just GCPostMember + _ -> Nothing + encodeText = \case + GCUserMember -> "user" + GCInviteeMember -> "invitee" + GCHostMember -> "host" + GCPreMember -> "pre" + GCPostMember -> "post" data GroupMemberStatus = GSMemRemoved -- member who was removed from the group @@ -344,9 +379,15 @@ data GroupMemberStatus | GSMemCreator -- user member that created the group (only GCUserMember) deriving (Eq, Show, Ord) -instance FromField GroupMemberStatus where fromField = fromTextField_ memberStatusT +instance FromField GroupMemberStatus where fromField = fromTextField_ decodeText -instance ToField GroupMemberStatus where toField = toField . serializeMemberStatus +instance ToField GroupMemberStatus where toField = toField . encodeText + +instance FromJSON GroupMemberStatus where parseJSON = textParseJSON "GroupMemberStatus" + +instance ToJSON GroupMemberStatus where + toJSON = J.String . encodeText + toEncoding = JE.text . encodeText memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of @@ -376,80 +417,69 @@ memberCurrent m = case memberStatus m of GSMemComplete -> True GSMemCreator -> True -memberStatusT :: Text -> Maybe GroupMemberStatus -memberStatusT = \case - "removed" -> Just GSMemRemoved - "left" -> Just GSMemLeft - "deleted" -> Just GSMemGroupDeleted - "invited" -> Just GSMemInvited - "introduced" -> Just GSMemIntroduced - "intro-inv" -> Just GSMemIntroInvited - "accepted" -> Just GSMemAccepted - "announced" -> Just GSMemAnnounced - "connected" -> Just GSMemConnected - "complete" -> Just GSMemComplete - "creator" -> Just GSMemCreator - _ -> Nothing - -serializeMemberStatus :: GroupMemberStatus -> Text -serializeMemberStatus = \case - GSMemRemoved -> "removed" - GSMemLeft -> "left" - GSMemGroupDeleted -> "deleted" - GSMemInvited -> "invited" - GSMemIntroduced -> "introduced" - GSMemIntroInvited -> "intro-inv" - GSMemAccepted -> "accepted" - GSMemAnnounced -> "announced" - GSMemConnected -> "connected" - GSMemComplete -> "complete" - GSMemCreator -> "creator" +instance TextEncoding GroupMemberStatus where + decodeText = \case + "removed" -> Just GSMemRemoved + "left" -> Just GSMemLeft + "deleted" -> Just GSMemGroupDeleted + "invited" -> Just GSMemInvited + "introduced" -> Just GSMemIntroduced + "intro-inv" -> Just GSMemIntroInvited + "accepted" -> Just GSMemAccepted + "announced" -> Just GSMemAnnounced + "connected" -> Just GSMemConnected + "complete" -> Just GSMemComplete + "creator" -> Just GSMemCreator + _ -> Nothing + encodeText = \case + GSMemRemoved -> "removed" + GSMemLeft -> "left" + GSMemGroupDeleted -> "deleted" + GSMemInvited -> "invited" + GSMemIntroduced -> "introduced" + GSMemIntroInvited -> "intro-inv" + GSMemAccepted -> "accepted" + GSMemAnnounced -> "announced" + GSMemConnected -> "connected" + GSMemComplete -> "complete" + GSMemCreator -> "creator" data SndFileTransfer = SndFileTransfer - { fileId :: Int64, + { fileId :: FileTransferId, fileName :: String, filePath :: String, fileSize :: Integer, chunkSize :: Integer, recipientDisplayName :: ContactName, connId :: Int64, - agentConnId :: ConnId, + agentConnId :: AgentConnId, fileStatus :: FileStatus } - deriving (Eq, Show) + deriving (Eq, Show, Generic) + +instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + +type FileTransferId = Int64 data FileInvitation = FileInvitation { fileName :: String, fileSize :: Integer, fileConnReq :: ConnReqInvitation } - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, FromJSON) -instance FromJSON FileInvitation where - parseJSON (J.Object v) = FileInvitation <$> v .: "fileName" <*> v .: "fileSize" <*> v .: "fileConnReq" - parseJSON invalid = JT.prependFailure "bad FileInvitation, " (JT.typeMismatch "Object" invalid) - -instance ToJSON FileInvitation where - toJSON (FileInvitation fileName fileSize fileConnReq) = - J.object - [ "fileName" .= fileName, - "fileSize" .= fileSize, - "fileConnReq" .= fileConnReq - ] - toEncoding (FileInvitation fileName fileSize fileConnReq) = - J.pairs $ - "fileName" .= fileName - <> "fileSize" .= fileSize - <> "fileConnReq" .= fileConnReq +instance ToJSON FileInvitation where toEncoding = J.genericToEncoding J.defaultOptions data RcvFileTransfer = RcvFileTransfer - { fileId :: Int64, + { fileId :: FileTransferId, fileInvitation :: FileInvitation, fileStatus :: RcvFileStatus, senderDisplayName :: ContactName, chunkSize :: Integer } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions data RcvFileStatus = RFSNew @@ -457,39 +487,95 @@ data RcvFileStatus | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo | RFSCancelled RcvFileInfo - deriving (Eq, Show) + deriving (Eq, Show, Generic) + +instance FromJSON RcvFileStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" + +instance ToJSON RcvFileStatus where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" data RcvFileInfo = RcvFileInfo { filePath :: FilePath, connId :: Int64, - agentConnId :: ConnId + agentConnId :: AgentConnId } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions + +newtype AgentConnId = AgentConnId ConnId deriving (Eq, Show) -data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer +instance StrEncoding AgentConnId where + strEncode (AgentConnId connId) = strEncode connId + strDecode s = AgentConnId <$> strDecode s + strP = AgentConnId <$> strP + +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + +instance ToJSON AgentConnId where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f + +instance ToField AgentConnId where toField (AgentConnId m) = toField m + +newtype AgentInvId = AgentInvId InvitationId + deriving (Eq, Show) + +instance StrEncoding AgentInvId where + strEncode (AgentInvId connId) = strEncode connId + strDecode s = AgentInvId <$> strDecode s + strP = AgentInvId <$> strP + +instance FromJSON AgentInvId where + parseJSON = strParseJSON "AgentInvId" + +instance ToJSON AgentInvId where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromField AgentInvId where fromField f = AgentInvId <$> fromField f + +instance ToField AgentInvId where toField (AgentInvId m) = toField m + +data FileTransfer = FTSnd {sndFileTransfers :: [SndFileTransfer]} | FTRcv RcvFileTransfer + deriving (Show, Generic) + +instance ToJSON FileTransfer where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) -instance FromField FileStatus where fromField = fromTextField_ fileStatusT +instance FromField FileStatus where fromField = fromTextField_ decodeText -instance ToField FileStatus where toField = toField . serializeFileStatus +instance ToField FileStatus where toField = toField . encodeText -fileStatusT :: Text -> Maybe FileStatus -fileStatusT = \case - "new" -> Just FSNew - "accepted" -> Just FSAccepted - "connected" -> Just FSConnected - "complete" -> Just FSComplete - "cancelled" -> Just FSCancelled - _ -> Nothing +instance FromJSON FileStatus where parseJSON = textParseJSON "FileStatus" -serializeFileStatus :: FileStatus -> Text -serializeFileStatus = \case - FSNew -> "new" - FSAccepted -> "accepted" - FSConnected -> "connected" - FSComplete -> "complete" - FSCancelled -> "cancelled" +instance ToJSON FileStatus where + toJSON = J.String . encodeText + toEncoding = JE.text . encodeText + +instance TextEncoding FileStatus where + decodeText = \case + "new" -> Just FSNew + "accepted" -> Just FSAccepted + "connected" -> Just FSConnected + "complete" -> Just FSComplete + "cancelled" -> Just FSCancelled + _ -> Nothing + encodeText = \case + FSNew -> "new" + FSAccepted -> "accepted" + FSConnected -> "connected" + FSComplete -> "complete" + FSCancelled -> "cancelled" data RcvChunkStatus = RcvChunkOk | RcvChunkFinal | RcvChunkDuplicate | RcvChunkError deriving (Eq, Show) @@ -500,7 +586,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, - agentConnId :: ConnId, + agentConnId :: AgentConnId, connLevel :: Int, viaContact :: Maybe Int64, connType :: ConnType, @@ -508,7 +594,14 @@ data Connection = Connection entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID createdAt :: UTCTime } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +aConnId :: Connection -> ConnId +aConnId Connection {agentConnId = AgentConnId cId} = cId + +instance ToJSON Connection where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data ConnStatus = -- | connection is created by initiating party with agent NEW command (createConnection) @@ -527,54 +620,62 @@ data ConnStatus ConnDeleted deriving (Eq, Show) -instance FromField ConnStatus where fromField = fromTextField_ connStatusT +instance FromField ConnStatus where fromField = fromTextField_ decodeText -instance ToField ConnStatus where toField = toField . serializeConnStatus +instance ToField ConnStatus where toField = toField . encodeText -connStatusT :: Text -> Maybe ConnStatus -connStatusT = \case - "new" -> Just ConnNew - "joined" -> Just ConnJoined - "requested" -> Just ConnRequested - "accepted" -> Just ConnAccepted - "snd-ready" -> Just ConnSndReady - "ready" -> Just ConnReady - "deleted" -> Just ConnDeleted - _ -> Nothing +instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" -serializeConnStatus :: ConnStatus -> Text -serializeConnStatus = \case - ConnNew -> "new" - ConnJoined -> "joined" - ConnRequested -> "requested" - ConnAccepted -> "accepted" - ConnSndReady -> "snd-ready" - ConnReady -> "ready" - ConnDeleted -> "deleted" +instance ToJSON ConnStatus where + toJSON = J.String . encodeText + toEncoding = JE.text . encodeText + +instance TextEncoding ConnStatus where + decodeText = \case + "new" -> Just ConnNew + "joined" -> Just ConnJoined + "requested" -> Just ConnRequested + "accepted" -> Just ConnAccepted + "snd-ready" -> Just ConnSndReady + "ready" -> Just ConnReady + "deleted" -> Just ConnDeleted + _ -> Nothing + encodeText = \case + ConnNew -> "new" + ConnJoined -> "joined" + ConnRequested -> "requested" + ConnAccepted -> "accepted" + ConnSndReady -> "snd-ready" + ConnReady -> "ready" + ConnDeleted -> "deleted" data ConnType = ConnContact | ConnMember | ConnSndFile | ConnRcvFile | ConnUserContact deriving (Eq, Show) -instance FromField ConnType where fromField = fromTextField_ connTypeT +instance FromField ConnType where fromField = fromTextField_ decodeText -instance ToField ConnType where toField = toField . serializeConnType +instance ToField ConnType where toField = toField . encodeText -connTypeT :: Text -> Maybe ConnType -connTypeT = \case - "contact" -> Just ConnContact - "member" -> Just ConnMember - "snd_file" -> Just ConnSndFile - "rcv_file" -> Just ConnRcvFile - "user_contact" -> Just ConnUserContact - _ -> Nothing +instance FromJSON ConnType where parseJSON = textParseJSON "ConnType" -serializeConnType :: ConnType -> Text -serializeConnType = \case - ConnContact -> "contact" - ConnMember -> "member" - ConnSndFile -> "snd_file" - ConnRcvFile -> "rcv_file" - ConnUserContact -> "user_contact" +instance ToJSON ConnType where + toJSON = J.String . encodeText + toEncoding = JE.text . encodeText + +instance TextEncoding ConnType where + decodeText = \case + "contact" -> Just ConnContact + "member" -> Just ConnMember + "snd_file" -> Just ConnSndFile + "rcv_file" -> Just ConnRcvFile + "user_contact" -> Just ConnUserContact + _ -> Nothing + encodeText = \case + ConnContact -> "contact" + ConnMember -> "member" + ConnSndFile -> "snd_file" + ConnRcvFile -> "rcv_file" + ConnUserContact -> "user_contact" data NewConnection = NewConnection { agentConnId :: ByteString, @@ -589,6 +690,7 @@ data GroupMemberIntro = GroupMemberIntro introStatus :: GroupMemberIntroStatus, introInvitation :: Maybe IntroInvitation } + deriving (Show) data GroupMemberIntroStatus = GMIntroPending @@ -598,6 +700,7 @@ data GroupMemberIntroStatus | GMIntroReConnected | GMIntroToConnected | GMIntroConnected + deriving (Show) instance FromField GroupMemberIntroStatus where fromField = fromTextField_ introStatusT @@ -624,122 +727,13 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data NewMessage = NewMessage - { direction :: MsgDirection, - chatMsgEventType :: Text, - msgBody :: MsgBody - } +data Notification = Notification {title :: Text, text :: Text} -type MessageId = Int64 +type JSONString = String -data MsgDirection = MDRcv | MDSnd +class TextEncoding a where + encodeText :: a -> Text + decodeText :: Text -> Maybe a -data SMsgDirection (d :: MsgDirection) where - SMDRcv :: SMsgDirection 'MDRcv - SMDSnd :: SMsgDirection 'MDSnd - -instance TestEquality SMsgDirection where - testEquality SMDRcv SMDRcv = Just Refl - testEquality SMDSnd SMDSnd = Just Refl - testEquality _ _ = Nothing - -class MsgDirectionI (d :: MsgDirection) where - msgDirection :: SMsgDirection d - -instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv - -instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd - -instance ToField MsgDirection where toField = toField . msgDirectionInt - -msgDirectionInt :: MsgDirection -> Int -msgDirectionInt = \case - MDRcv -> 0 - MDSnd -> 1 - -msgDirectionIntP :: Int -> Maybe MsgDirection -msgDirectionIntP = \case - 0 -> Just MDRcv - 1 -> Just MDSnd - _ -> Nothing - -data SndMsgDelivery = SndMsgDelivery - { connId :: Int64, - agentMsgId :: AgentMsgId - } - -data RcvMsgDelivery = RcvMsgDelivery - { connId :: Int64, - agentMsgId :: AgentMsgId, - agentMsgMeta :: MsgMeta - } - -data MsgMetaJSON = MsgMetaJSON - { integrity :: Text, - rcvId :: Int64, - rcvTs :: UTCTime, - serverId :: Text, - serverTs :: UTCTime, - sndId :: Int64 - } - deriving (Eq, Show, FromJSON, Generic) - -instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions - -msgMetaToJson :: MsgMeta -> MsgMetaJSON -msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = - MsgMetaJSON - { integrity = (decodeLatin1 . serializeMsgIntegrity) integrity, - rcvId, - rcvTs, - serverId = (decodeLatin1 . B64.encode) serverId, - serverTs, - sndId - } - -msgMetaJson :: MsgMeta -> Text -msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson - -data MsgDeliveryStatus (d :: MsgDirection) where - MDSRcvAgent :: MsgDeliveryStatus 'MDRcv - MDSRcvAcknowledged :: MsgDeliveryStatus 'MDRcv - MDSSndPending :: MsgDeliveryStatus 'MDSnd - MDSSndAgent :: MsgDeliveryStatus 'MDSnd - MDSSndSent :: MsgDeliveryStatus 'MDSnd - MDSSndReceived :: MsgDeliveryStatus 'MDSnd - MDSSndRead :: MsgDeliveryStatus 'MDSnd - -data AMsgDeliveryStatus = forall d. AMDS (SMsgDirection d) (MsgDeliveryStatus d) - -instance (Typeable d, MsgDirectionI d) => FromField (MsgDeliveryStatus d) where - fromField = fromTextField_ msgDeliveryStatusT' - -instance ToField (MsgDeliveryStatus d) where toField = toField . serializeMsgDeliveryStatus - -serializeMsgDeliveryStatus :: MsgDeliveryStatus d -> Text -serializeMsgDeliveryStatus = \case - MDSRcvAgent -> "rcv_agent" - MDSRcvAcknowledged -> "rcv_acknowledged" - MDSSndPending -> "snd_pending" - MDSSndAgent -> "snd_agent" - MDSSndSent -> "snd_sent" - MDSSndReceived -> "snd_received" - MDSSndRead -> "snd_read" - -msgDeliveryStatusT :: Text -> Maybe AMsgDeliveryStatus -msgDeliveryStatusT = \case - "rcv_agent" -> Just $ AMDS SMDRcv MDSRcvAgent - "rcv_acknowledged" -> Just $ AMDS SMDRcv MDSRcvAcknowledged - "snd_pending" -> Just $ AMDS SMDSnd MDSSndPending - "snd_agent" -> Just $ AMDS SMDSnd MDSSndAgent - "snd_sent" -> Just $ AMDS SMDSnd MDSSndSent - "snd_received" -> Just $ AMDS SMDSnd MDSSndReceived - "snd_read" -> Just $ AMDS SMDSnd MDSSndRead - _ -> Nothing - -msgDeliveryStatusT' :: forall d. MsgDirectionI d => Text -> Maybe (MsgDeliveryStatus d) -msgDeliveryStatusT' s = - msgDeliveryStatusT s >>= \(AMDS d st) -> - case testEquality d (msgDirection @d) of - Just Refl -> Just st - _ -> Nothing +textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a +textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . decodeText diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 1244774c50..a5e4c8b1e0 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -18,3 +18,6 @@ whenM ba a = ba >>= (`when` a) unlessM :: Monad m => m Bool -> m () -> m () unlessM b = ifM b $ pure () + +eitherToMaybe :: Either a b -> Maybe b +eitherToMaybe = either (const Nothing) Just diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 96499a999e..ffbf8f1ce2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,333 +1,194 @@ -{-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -module Simplex.Chat.View - ( printToView, - showInvitation, - showSentConfirmation, - showSentInvitation, - showInvalidConnReq, - showChatError, - showContactDeleted, - showContactGroups, - showContactsList, - showContactConnected, - showContactDisconnected, - showContactAnotherClient, - showContactSubscribed, - showContactSubError, - showUserContactLinkCreated, - showUserContactLinkDeleted, - showUserContactLink, - showReceivedContactRequest, - showAcceptingContactRequest, - showContactRequestRejected, - showUserContactLinkSubscribed, - showUserContactLinkSubError, - showGroupSubscribed, - showGroupEmpty, - showGroupRemoved, - showGroupInvitation, - showMemberSubError, - showReceivedMessage, - showReceivedGroupMessage, - showSentMessage, - showSentGroupMessage, - showSentFileInvitation, - showSentGroupFileInvitation, - showSentFileInfo, - showSndFileStart, - showSndFileComplete, - showSndFileCancelled, - showSndGroupFileCancelled, - showSndFileRcvCancelled, - receivedFileInvitation, - showRcvFileAccepted, - showRcvFileStart, - showRcvFileComplete, - showRcvFileCancelled, - showRcvFileSndCancelled, - showFileTransferStatus, - showSndFileSubError, - showRcvFileSubError, - showGroupCreated, - showGroupDeletedUser, - showGroupDeleted, - showSentGroupInvitation, - showCannotResendInvitation, - showReceivedGroupInvitation, - showJoinedGroupMember, - showUserJoinedGroup, - showJoinedGroupMemberConnecting, - showConnectedToGroupMember, - showDeletedMember, - showDeletedMemberUser, - showLeftMemberUser, - showLeftMember, - showGroupMembers, - showGroupsList, - showContactsMerged, - showUserProfile, - showUserProfileUpdated, - showContactUpdated, - showMessageError, - safeDecodeUtf8, - msgPlain, - clientVersionInfo, - ) -where +module Simplex.Chat.View where -import Control.Monad.IO.Unlift -import Control.Monad.Reader -import Data.ByteString.Char8 (ByteString) -import Data.Composition ((.:), (.:.)) import Data.Function (on) import Data.Int (Int64) -import Data.List (groupBy, intersperse, sort, sortOn) +import Data.List (groupBy, intersperse, sortOn) import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (DiffTime, UTCTime) +import Data.Time.Clock (DiffTime) import Data.Time.Format (defaultTimeLocale, formatTime) -import Data.Time.LocalTime (TimeZone, ZonedTime, getCurrentTimeZone, getZonedTime, localDay, localTimeOfDay, timeOfDayToTime, utcToLocalTime, zonedTimeToLocalTime) +import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime) import Numeric (showFFloat) import Simplex.Chat.Controller +import Simplex.Chat.Help import Simplex.Chat.Markdown +import Simplex.Chat.Messages hiding (NewChatItem (..)) +import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError (..)) import Simplex.Chat.Styled -import Simplex.Chat.Terminal (printToTerminal) import Simplex.Chat.Types -import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Encoding.String import qualified Simplex.Messaging.Protocol as SMP import System.Console.ANSI.Types -type ChatReader m = (MonadUnliftIO m, MonadReader ChatController m) - -showInvitation :: ChatReader m => ConnReqInvitation -> m () -showInvitation = printToView . connReqInvitation_ - -showSentConfirmation :: ChatReader m => m () -showSentConfirmation = printToView ["confirmation sent!"] - -showSentInvitation :: ChatReader m => m () -showSentInvitation = printToView ["connection request sent!"] - -showInvalidConnReq :: ChatReader m => m () -showInvalidConnReq = - printToView - [ "", - "Connection link is invalid, possibly it was created in a previous version.", - "Please ask your contact to check " <> highlight' "/version" <> " and update if needed.", - plain updateStr - ] - -showChatError :: ChatReader m => ChatError -> m () -showChatError = printToView . chatError - -showContactDeleted :: ChatReader m => ContactName -> m () -showContactDeleted = printToView . contactDeleted - -showContactGroups :: ChatReader m => ContactName -> [GroupName] -> m () -showContactGroups = printToView .: contactGroups - -showContactsList :: ChatReader m => [Contact] -> m () -showContactsList = printToView . contactsList - -showContactConnected :: ChatReader m => Contact -> m () -showContactConnected = printToView . contactConnected - -showContactDisconnected :: ChatReader m => ContactName -> m () -showContactDisconnected = printToView . contactDisconnected - -showContactAnotherClient :: ChatReader m => ContactName -> m () -showContactAnotherClient = printToView . contactAnotherClient - -showContactSubscribed :: ChatReader m => ContactName -> m () -showContactSubscribed = printToView . contactSubscribed - -showContactSubError :: ChatReader m => ContactName -> ChatError -> m () -showContactSubError = printToView .: contactSubError - -showUserContactLinkCreated :: ChatReader m => ConnReqContact -> m () -showUserContactLinkCreated = printToView . userContactLinkCreated - -showUserContactLinkDeleted :: ChatReader m => m () -showUserContactLinkDeleted = printToView userContactLinkDeleted - -showUserContactLink :: ChatReader m => ConnReqContact -> m () -showUserContactLink = printToView . userContactLink - -showReceivedContactRequest :: ChatReader m => ContactName -> Profile -> m () -showReceivedContactRequest = printToView .: receivedContactRequest - -showAcceptingContactRequest :: ChatReader m => ContactName -> m () -showAcceptingContactRequest = printToView . acceptingContactRequest - -showContactRequestRejected :: ChatReader m => ContactName -> m () -showContactRequestRejected = printToView . contactRequestRejected - -showUserContactLinkSubscribed :: ChatReader m => m () -showUserContactLinkSubscribed = printToView ["Your address is active! To show: " <> highlight' "/sa"] - -showUserContactLinkSubError :: ChatReader m => ChatError -> m () -showUserContactLinkSubError = printToView . userContactLinkSubError - -showGroupSubscribed :: ChatReader m => Group -> m () -showGroupSubscribed = printToView . groupSubscribed - -showGroupEmpty :: ChatReader m => Group -> m () -showGroupEmpty = printToView . groupEmpty - -showGroupRemoved :: ChatReader m => Group -> m () -showGroupRemoved = printToView . groupRemoved - -showGroupInvitation :: ChatReader m => Group -> m () -showGroupInvitation Group {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = - printToView [groupInvitation ldn fullName] - -showMemberSubError :: ChatReader m => GroupName -> ContactName -> ChatError -> m () -showMemberSubError = printToView .:. memberSubError - -showReceivedMessage :: ChatReader m => ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> m () -showReceivedMessage = showReceivedMessage_ . ttyFromContact - -showReceivedGroupMessage :: ChatReader m => GroupName -> ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> m () -showReceivedGroupMessage = showReceivedMessage_ .: ttyFromGroup - -showReceivedMessage_ :: ChatReader m => StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> m () -showReceivedMessage_ from utcTime msg mOk = printToView =<< liftIO (receivedMessage from utcTime msg mOk) - -showSentMessage :: ChatReader m => ContactName -> ByteString -> m () -showSentMessage = showSentMessage_ . ttyToContact - -showSentGroupMessage :: ChatReader m => GroupName -> ByteString -> m () -showSentGroupMessage = showSentMessage_ . ttyToGroup - -showSentMessage_ :: ChatReader m => StyledString -> ByteString -> m () -showSentMessage_ to msg = printToView =<< liftIO (sentMessage to msg) - -showSentFileInvitation :: ChatReader m => ContactName -> FilePath -> m () -showSentFileInvitation = showSentFileInvitation_ . ttyToContact - -showSentGroupFileInvitation :: ChatReader m => GroupName -> FilePath -> m () -showSentGroupFileInvitation = showSentFileInvitation_ . ttyToGroup - -showSentFileInvitation_ :: ChatReader m => StyledString -> FilePath -> m () -showSentFileInvitation_ to filePath = printToView =<< liftIO (sentFileInvitation to filePath) - -showSentFileInfo :: ChatReader m => Int64 -> m () -showSentFileInfo = printToView . sentFileInfo - -showSndFileStart :: ChatReader m => SndFileTransfer -> m () -showSndFileStart = printToView . sndFileStart - -showSndFileComplete :: ChatReader m => SndFileTransfer -> m () -showSndFileComplete = printToView . sndFileComplete - -showSndFileCancelled :: ChatReader m => SndFileTransfer -> m () -showSndFileCancelled = printToView . sndFileCancelled - -showSndGroupFileCancelled :: ChatReader m => [SndFileTransfer] -> m () -showSndGroupFileCancelled = printToView . sndGroupFileCancelled - -showSndFileRcvCancelled :: ChatReader m => SndFileTransfer -> m () -showSndFileRcvCancelled = printToView . sndFileRcvCancelled - -showRcvFileAccepted :: ChatReader m => RcvFileTransfer -> FilePath -> m () -showRcvFileAccepted = printToView .: rcvFileAccepted - -showRcvFileStart :: ChatReader m => RcvFileTransfer -> m () -showRcvFileStart = printToView . rcvFileStart - -showRcvFileComplete :: ChatReader m => RcvFileTransfer -> m () -showRcvFileComplete = printToView . rcvFileComplete - -showRcvFileCancelled :: ChatReader m => RcvFileTransfer -> m () -showRcvFileCancelled = printToView . rcvFileCancelled - -showRcvFileSndCancelled :: ChatReader m => RcvFileTransfer -> m () -showRcvFileSndCancelled = printToView . rcvFileSndCancelled - -showFileTransferStatus :: ChatReader m => (FileTransfer, [Integer]) -> m () -showFileTransferStatus = printToView . fileTransferStatus - -showSndFileSubError :: ChatReader m => SndFileTransfer -> ChatError -> m () -showSndFileSubError = printToView .: sndFileSubError - -showRcvFileSubError :: ChatReader m => RcvFileTransfer -> ChatError -> m () -showRcvFileSubError = printToView .: rcvFileSubError - -showGroupCreated :: ChatReader m => Group -> m () -showGroupCreated = printToView . groupCreated - -showGroupDeletedUser :: ChatReader m => GroupName -> m () -showGroupDeletedUser = printToView . groupDeletedUser - -showGroupDeleted :: ChatReader m => GroupName -> GroupMember -> m () -showGroupDeleted = printToView .: groupDeleted - -showSentGroupInvitation :: ChatReader m => GroupName -> ContactName -> m () -showSentGroupInvitation = printToView .: sentGroupInvitation - -showCannotResendInvitation :: ChatReader m => GroupName -> ContactName -> m () -showCannotResendInvitation = printToView .: cannotResendInvitation - -showReceivedGroupInvitation :: ChatReader m => Group -> ContactName -> GroupMemberRole -> m () -showReceivedGroupInvitation = printToView .:. receivedGroupInvitation - -showJoinedGroupMember :: ChatReader m => GroupName -> GroupMember -> m () -showJoinedGroupMember = printToView .: joinedGroupMember - -showUserJoinedGroup :: ChatReader m => GroupName -> m () -showUserJoinedGroup = printToView . userJoinedGroup - -showJoinedGroupMemberConnecting :: ChatReader m => GroupName -> GroupMember -> GroupMember -> m () -showJoinedGroupMemberConnecting = printToView .:. joinedGroupMemberConnecting - -showConnectedToGroupMember :: ChatReader m => GroupName -> GroupMember -> m () -showConnectedToGroupMember = printToView .: connectedToGroupMember - -showDeletedMember :: ChatReader m => GroupName -> Maybe GroupMember -> Maybe GroupMember -> m () -showDeletedMember = printToView .:. deletedMember - -showDeletedMemberUser :: ChatReader m => GroupName -> GroupMember -> m () -showDeletedMemberUser = printToView .: deletedMemberUser - -showLeftMemberUser :: ChatReader m => GroupName -> m () -showLeftMemberUser = printToView . leftMemberUser - -showLeftMember :: ChatReader m => GroupName -> GroupMember -> m () -showLeftMember = printToView .: leftMember - -showGroupMembers :: ChatReader m => Group -> m () -showGroupMembers = printToView . groupMembers - -showGroupsList :: ChatReader m => [(GroupName, Text, GroupMemberStatus)] -> m () -showGroupsList = printToView . groupsList - -showContactsMerged :: ChatReader m => Contact -> Contact -> m () -showContactsMerged = printToView .: contactsMerged - -showUserProfile :: ChatReader m => Profile -> m () -showUserProfile = printToView . userProfile - -showUserProfileUpdated :: ChatReader m => User -> User -> m () -showUserProfileUpdated = printToView .: userProfileUpdated - -showContactUpdated :: ChatReader m => Contact -> Contact -> m () -showContactUpdated = printToView .: contactUpdated - -showMessageError :: ChatReader m => Text -> Text -> m () -showMessageError = printToView .: messageError - -connReqInvitation_ :: ConnReqInvitation -> [StyledString] -connReqInvitation_ cReq = +serializeChatResponse :: ChatResponse -> String +serializeChatResponse = unlines . map unStyle . responseToView "" False + +responseToView :: String -> Bool -> ChatResponse -> [StyledString] +responseToView cmd testView = \case + CRActiveUser User {profile} -> r $ viewUserProfile profile + CRChatStarted -> r ["chat started"] + CRApiChats chats -> r $ if testView then testViewChats chats else [sShow chats] + CRApiChat chat -> r $ if testView then testViewChat chat else [sShow chat] + CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item + CRChatItemUpdated _ -> [] + CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr + CRCmdAccepted _ -> r [] + CRCmdOk -> r ["ok"] + CRChatHelp section -> case section of + HSMain -> r chatHelpInfo + HSFiles -> r filesHelpInfo + HSGroups -> r groupsHelpInfo + HSMyAddress -> r myAddressHelpInfo + HSMarkdown -> r markdownInfo + CRWelcome user -> r $ chatWelcome user + CRContactsList cs -> r $ viewContactsList cs + CRUserContactLink cReq -> r $ connReqContact_ "Your chat address:" cReq + CRContactRequestRejected UserContactRequest {localDisplayName = c} -> r [ttyContact c <> ": contact request rejected"] + CRGroupCreated g -> r $ viewGroupCreated g + CRGroupMembers g -> r $ viewGroupMembers g + CRGroupsList gs -> r $ viewGroupsList gs + CRSentGroupInvitation g c -> r ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + CRFileTransferStatus ftStatus -> r $ viewFileTransferStatus ftStatus + CRUserProfile p -> r $ viewUserProfile p + CRUserProfileNoChange -> r ["user profile did not change"] + CRVersionInfo -> r [plain versionStr, plain updateStr] + CRChatCmdError e -> r $ viewChatError e + CRInvitation cReq -> r' $ viewConnReqInvitation cReq + CRSentConfirmation -> r' ["confirmation sent!"] + CRSentInvitation -> r' ["connection request sent!"] + CRContactDeleted Contact {localDisplayName = c} -> r' [ttyContact c <> ": contact is deleted"] + CRAcceptingContactRequest Contact {localDisplayName = c} -> r' [ttyContact c <> ": accepting contact request..."] + CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted + CRUserAcceptedGroupSent _g -> r' [] -- [ttyGroup' g <> ": joining the group..."] + CRUserDeletedMember g m -> r' [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRLeftMemberUser g -> r' $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRGroupDeletedUser g -> r' [ttyGroup' g <> ": you deleted the group"] + CRRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath -> + r' ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] + CRRcvFileAcceptedSndCancelled ft -> r' $ viewRcvFileSndCancelled ft + CRSndGroupFileCancelled fts -> r' $ viewSndGroupFileCancelled fts + CRRcvFileCancelled ft -> r' $ receivingFile_ "cancelled" ft + CRUserProfileUpdated p p' -> r' $ viewUserProfileUpdated p p' + CRContactUpdated c c' -> viewContactUpdated c c' + CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt + CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile + CRRcvFileStart ft -> receivingFile_ "started" ft + CRRcvFileComplete ft -> receivingFile_ "completed" ft + CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft + CRSndFileStart ft -> sendingFile_ "started" ft + CRSndFileComplete ft -> sendingFile_ "completed" ft + CRSndFileCancelled ft -> sendingFile_ "cancelled" ft + CRSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} -> + [ttyContact c <> " cancelled receiving " <> sndFile ft] + CRContactConnecting _ -> [] + CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"] + CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] + CRContactDisconnected c -> [ttyContact' c <> ": disconnected from server (messages will be queued)"] + CRContactSubscribed c -> [ttyContact' c <> ": connected to server"] + CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] + 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 "] + 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 + CRDeletedMember g by m -> [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] + CRLeftMember g m -> [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CRGroupEmpty g -> [ttyFullGroup g <> ": group is empty"] + CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupDeleted g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] + CRMemberSubError g c e -> [ttyGroup' g <> " member " <> ttyContact c <> " error: " <> sShow e] + CRGroupSubscribed g -> [ttyFullGroup g <> ": connected to server(s)"] + CRSndFileSubError SndFileTransfer {fileId, fileName} e -> + ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> + ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] + CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] + CRMessageError prefix err -> [plain prefix <> ": " <> plain err] + CRChatError e -> viewChatError e + where + r = (plain cmd :) + -- this function should be `r` for "synchronous", `id` for "asynchronous" command responses + -- r' = r + r' = id + testViewChats :: [AChat] -> [StyledString] + testViewChats chats = [sShow $ map toChatView chats] + where + toChatView :: AChat -> (Text, Text) + toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName}) items _)) = ("@" <> localDisplayName, toCIPreview items) + toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items) + toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items) + toCIPreview :: [CChatItem c] -> Text + toCIPreview ((CChatItem _ ChatItem {meta}) : _) = itemText meta + toCIPreview _ = "" + testViewChat :: AChat -> [StyledString] + testViewChat (AChat _ Chat {chatItems}) = [sShow $ map toChatView chatItems] + where + toChatView :: CChatItem c -> (Int, Text) + toChatView (CChatItem dir ChatItem {meta}) = (msgDirectionInt $ toMsgDirection dir, itemText meta) + +viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] +viewChatItem chat (ChatItem cd meta content) = case (chat, cd) of + (DirectChat c, CIDirectSnd) -> case content of + CISndMsgContent mc -> viewSentMessage to mc meta + CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta + where + to = ttyToContact' c + (DirectChat c, CIDirectRcv) -> case content of + CIRcvMsgContent mc -> viewReceivedMessage from meta mc -- mOk + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk + where + from = ttyFromContact' c + (GroupChat g, CIGroupSnd) -> case content of + CISndMsgContent mc -> viewSentMessage to mc meta + CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta + where + to = ttyToGroup g + (GroupChat g, CIGroupRcv m) -> case content of + CIRcvMsgContent mc -> viewReceivedMessage from meta mc -- mOk + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk + where + from = ttyFromGroup' g m + where + ttyToContact' Contact {localDisplayName = c} = ttyToContact c + ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c + ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m + +viewMsgIntegrityError :: MsgErrorType -> [StyledString] +viewMsgIntegrityError err = msgError $ case err of + MsgSkipped fromId toId -> + "skipped message ID " <> show fromId + <> if fromId == toId then "" else ".." <> show toId + MsgBadId msgId -> "unexpected message ID " <> show msgId + MsgBadHash -> "incorrect message hash" + MsgDuplicate -> "duplicate message ID" + where + msgError :: String -> [StyledString] + msgError s = [styled (Colored Red) s] + +viewInvalidConnReq :: [StyledString] +viewInvalidConnReq = + [ "", + "Connection link is invalid, possibly it was created in a previous version.", + "Please ask your contact to check " <> highlight' "/version" <> " and update if needed.", + plain updateStr + ] + +viewConnReqInvitation :: ConnReqInvitation -> [StyledString] +viewConnReqInvitation cReq = [ "pass this invitation link to your contact (via another channel): ", "", (plain . strEncode) cReq, @@ -335,49 +196,17 @@ connReqInvitation_ cReq = "and ask them to connect: " <> highlight' "/c " ] -contactDeleted :: ContactName -> [StyledString] -contactDeleted c = [ttyContact c <> ": contact is deleted"] - -contactGroups :: ContactName -> [GroupName] -> [StyledString] -contactGroups c gNames = [ttyContact c <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames] - where - ttyGroups :: [GroupName] -> StyledString - ttyGroups [] = "" - ttyGroups [g] = ttyGroup g - ttyGroups (g : gs) = ttyGroup g <> ", " <> ttyGroups gs - -contactsList :: [Contact] -> [StyledString] -contactsList = +viewContactsList :: [Contact] -> [StyledString] +viewContactsList = let ldn = T.toLower . (localDisplayName :: Contact -> ContactName) in map ttyFullContact . sortOn ldn -contactConnected :: Contact -> [StyledString] -contactConnected ct = [ttyFullContact ct <> ": contact is connected"] - -contactDisconnected :: ContactName -> [StyledString] -contactDisconnected c = [ttyContact c <> ": disconnected from server (messages will be queued)"] - -contactAnotherClient :: ContactName -> [StyledString] -contactAnotherClient c = [ttyContact c <> ": contact is connected to another client"] - -contactSubscribed :: ContactName -> [StyledString] -contactSubscribed c = [ttyContact c <> ": connected to server"] - -contactSubError :: ContactName -> ChatError -> [StyledString] -contactSubError c e = [ttyContact c <> ": contact error " <> sShow e] - -userContactLinkCreated :: ConnReqContact -> [StyledString] -userContactLinkCreated = connReqContact_ "Your new chat address is created!" - -userContactLinkDeleted :: [StyledString] -userContactLinkDeleted = +viewUserContactLinkDeleted :: [StyledString] +viewUserContactLinkDeleted = [ "Your chat address is deleted - accepted contacts will remain connected.", "To create a new chat address use " <> highlight' "/ad" ] -userContactLink :: ConnReqContact -> [StyledString] -userContactLink = connReqContact_ "Your chat address:" - connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = [ intro, @@ -389,99 +218,33 @@ connReqContact_ intro cReq = "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] -receivedContactRequest :: ContactName -> Profile -> [StyledString] -receivedContactRequest c Profile {fullName} = +viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] +viewReceivedContactRequest c Profile {fullName} = [ ttyFullName c fullName <> " wants to connect to you!", "to accept: " <> highlight ("/ac " <> c), "to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)" ] -acceptingContactRequest :: ContactName -> [StyledString] -acceptingContactRequest c = [ttyContact c <> ": accepting contact request..."] - -contactRequestRejected :: ContactName -> [StyledString] -contactRequestRejected c = [ttyContact c <> ": contact request rejected"] - -userContactLinkSubError :: ChatError -> [StyledString] -userContactLinkSubError e = - [ "user address error: " <> sShow e, - "to delete your address: " <> highlight' "/da" - ] - -groupSubscribed :: Group -> [StyledString] -groupSubscribed g = [ttyFullGroup g <> ": connected to server(s)"] - -groupEmpty :: Group -> [StyledString] -groupEmpty g = [ttyFullGroup g <> ": group is empty"] - -groupRemoved :: Group -> [StyledString] -groupRemoved g = [ttyFullGroup g <> ": you are no longer a member or group deleted"] - -memberSubError :: GroupName -> ContactName -> ChatError -> [StyledString] -memberSubError g c e = [ttyGroup g <> " member " <> ttyContact c <> " error: " <> sShow e] - -groupCreated :: Group -> [StyledString] -groupCreated g@Group {localDisplayName} = +viewGroupCreated :: GroupInfo -> [StyledString] +viewGroupCreated g@GroupInfo {localDisplayName} = [ "group " <> ttyFullGroup g <> " is created", "use " <> highlight ("/a " <> localDisplayName <> " ") <> " to add members" ] -groupDeletedUser :: GroupName -> [StyledString] -groupDeletedUser g = groupDeleted_ g Nothing - -groupDeleted :: GroupName -> GroupMember -> [StyledString] -groupDeleted g m = groupDeleted_ g (Just m) <> ["use " <> highlight ("/d #" <> g) <> " to delete the local copy of the group"] - -groupDeleted_ :: GroupName -> Maybe GroupMember -> [StyledString] -groupDeleted_ g m = [ttyGroup g <> ": " <> memberOrUser m <> " deleted the group"] - -sentGroupInvitation :: GroupName -> ContactName -> [StyledString] -sentGroupInvitation g c = ["invitation to join the group " <> ttyGroup g <> " sent to " <> ttyContact c] - -cannotResendInvitation :: GroupName -> ContactName -> [StyledString] -cannotResendInvitation g c = - [ ttyContact c <> " is already invited to group " <> ttyGroup g, - "to re-send invitation: " <> highlight ("/rm " <> g <> " " <> c) <> ", " <> highlight ("/a " <> g <> " " <> c) +viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] +viewCannotResendInvitation GroupInfo {localDisplayName = gn} c = + [ ttyContact c <> " is already invited to group " <> ttyGroup gn, + "to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c) ] -receivedGroupInvitation :: Group -> ContactName -> GroupMemberRole -> [StyledString] -receivedGroupInvitation g@Group {localDisplayName} c role = - [ ttyFullGroup g <> ": " <> ttyContact c <> " invites you to join the group as " <> plain (strEncode role), - "use " <> highlight ("/j " <> localDisplayName) <> " to accept" +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" ] -joinedGroupMember :: GroupName -> GroupMember -> [StyledString] -joinedGroupMember g m = [ttyGroup g <> ": " <> ttyMember m <> " joined the group "] - -userJoinedGroup :: GroupName -> [StyledString] -userJoinedGroup g = [ttyGroup g <> ": you joined the group"] - -joinedGroupMemberConnecting :: GroupName -> GroupMember -> GroupMember -> [StyledString] -joinedGroupMemberConnecting g host m = [ttyGroup g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - -connectedToGroupMember :: GroupName -> GroupMember -> [StyledString] -connectedToGroupMember g m = [ttyGroup g <> ": " <> connectedMember m <> " is connected"] - -deletedMember :: GroupName -> Maybe GroupMember -> Maybe GroupMember -> [StyledString] -deletedMember g by m = [ttyGroup g <> ": " <> memberOrUser by <> " removed " <> memberOrUser m <> " from the group"] - -deletedMemberUser :: GroupName -> GroupMember -> [StyledString] -deletedMemberUser g by = deletedMember g (Just by) Nothing <> groupPreserved g - -leftMemberUser :: GroupName -> [StyledString] -leftMemberUser g = leftMember_ g Nothing <> groupPreserved g - -leftMember :: GroupName -> GroupMember -> [StyledString] -leftMember g m = leftMember_ g (Just m) - -leftMember_ :: GroupName -> Maybe GroupMember -> [StyledString] -leftMember_ g m = [ttyGroup g <> ": " <> memberOrUser m <> " left the group"] - -groupPreserved :: GroupName -> [StyledString] -groupPreserved g = ["use " <> highlight ("/d #" <> g) <> " to delete the group"] - -memberOrUser :: Maybe GroupMember -> StyledString -memberOrUser = maybe "you" ttyMember +groupPreserved :: GroupInfo -> [StyledString] +groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"] connectedMember :: GroupMember -> StyledString connectedMember m = case memberCategory m of @@ -489,8 +252,8 @@ connectedMember m = case memberCategory m of GCPostMember -> "new member " <> ttyMember m -- without fullName as as it was shown in joinedGroupMemberConnecting _ -> "member " <> ttyMember m -- these case is not used -groupMembers :: Group -> [StyledString] -groupMembers Group {membership, members} = map groupMember . filter (not . removedOrLeft) $ membership : members +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 @@ -509,12 +272,15 @@ groupMembers Group {membership, members} = map groupMember . filter (not . remov GSMemCreator -> "created group" _ -> "" -groupsList :: [(GroupName, Text, GroupMemberStatus)] -> [StyledString] -groupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] -groupsList gs = map groupSS $ sort gs +viewGroupsList :: [GroupInfo] -> [StyledString] +viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] +viewGroupsList gs = map groupSS $ sortOn ldn_ gs where - groupSS (displayName, fullName, GSMemInvited) = groupInvitation displayName fullName - groupSS (displayName, fullName, _) = ttyGroup displayName <> optFullName displayName fullName + ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) + groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} = + case memberStatus membership of + GSMemInvited -> groupInvitation ldn fullName + _ -> ttyGroup ldn <> optFullName ldn fullName groupInvitation :: GroupName -> Text -> StyledString groupInvitation displayName fullName = @@ -526,31 +292,29 @@ groupInvitation displayName fullName = <> highlight ("/d #" <> displayName) <> " to delete invitation)" -contactsMerged :: Contact -> Contact -> [StyledString] -contactsMerged _to@Contact {localDisplayName = c1} _from@Contact {localDisplayName = c2} = +viewContactsMerged :: Contact -> Contact -> [StyledString] +viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} = [ "contact " <> ttyContact c2 <> " is merged into " <> ttyContact c1, "use " <> ttyToContact c1 <> highlight' "" <> " to send messages" ] -userProfile :: Profile -> [StyledString] -userProfile Profile {displayName, fullName} = +viewUserProfile :: Profile -> [StyledString] +viewUserProfile Profile {displayName, fullName} = [ "user profile: " <> ttyFullName displayName fullName, "use " <> highlight' "/p []" <> " to change it", "(the updated profile will be sent to all your contacts)" ] -userProfileUpdated :: User -> User -> [StyledString] -userProfileUpdated - User {localDisplayName = n, profile = Profile {fullName}} - User {localDisplayName = n', profile = Profile {fullName = fullName'}} - | n == n' && fullName == fullName' = [] - | n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified] - | otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified] - where - notified = " (your contacts are notified)" +viewUserProfileUpdated :: Profile -> Profile -> [StyledString] +viewUserProfileUpdated Profile {displayName = n, fullName} Profile {displayName = n', fullName = fullName'} + | n == n' && fullName == fullName' = [] + | n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified] + | otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified] + where + notified = " (your contacts are notified)" -contactUpdated :: Contact -> Contact -> [StyledString] -contactUpdated +viewContactUpdated :: Contact -> Contact -> [StyledString] +viewContactUpdated Contact {localDisplayName = n, profile = Profile {fullName}} Contact {localDisplayName = n', profile = Profile {fullName = fullName'}} | n == n' && fullName == fullName' = [] @@ -562,45 +326,44 @@ contactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -messageError :: Text -> Text -> [StyledString] -messageError prefix err = [plain prefix <> ": " <> plain err] +viewReceivedMessage :: StyledString -> CIMeta d -> MsgContent -> [StyledString] +viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) -receivedMessage :: StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] -receivedMessage from utcTime msg mOk = do - t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime - pure $ prependFirst (t <> " " <> from) msg ++ showIntegrity mOk +receivedWithTime_ :: StyledString -> CIMeta d -> [StyledString] -> [StyledString] +receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do + prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk where - formatUTCTime :: TimeZone -> ZonedTime -> StyledString - formatUTCTime localTz currentTime = - let localTime = utcToLocalTime localTz utcTime + formattedTime :: StyledString + formattedTime = + let localTime = zonedTimeToLocalTime localItemTs + tz = zonedTimeZone localItemTs format = - if (localDay localTime < localDay (zonedTimeToLocalTime currentTime)) + if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz createdAt)) && (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime)) then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight else "%H:%M" in styleTime $ formatTime defaultTimeLocale format localTime - showIntegrity :: MsgIntegrity -> [StyledString] - showIntegrity MsgOk = [] - showIntegrity (MsgError err) = msgError $ case err of - MsgSkipped fromId toId -> - "skipped message ID " <> show fromId - <> if fromId == toId then "" else ".." <> show toId - MsgBadId msgId -> "unexpected message ID " <> show msgId - MsgBadHash -> "incorrect message hash" - MsgDuplicate -> "duplicate message ID" - msgError :: String -> [StyledString] - msgError s = [styled (Colored Red) s] -sentMessage :: StyledString -> ByteString -> IO [StyledString] -sentMessage to msg = sendWithTime_ to . msgPlain $ safeDecodeUtf8 msg +viewSentMessage :: StyledString -> MsgContent -> CIMeta d -> [StyledString] +viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent -sentFileInvitation :: StyledString -> FilePath -> IO [StyledString] -sentFileInvitation to f = sendWithTime_ ("/f " <> to) [ttyFilePath f] +viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString] +viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath -sendWithTime_ :: StyledString -> [StyledString] -> IO [StyledString] -sendWithTime_ to styledMsg = do - time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime - pure $ prependFirst (styleTime time <> " " <> to) styledMsg +sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString] +sentWithTime_ styledMsg CIMeta {localItemTs} = + prependFirst (ttyMsgTime localItemTs <> " ") styledMsg + +ttyMsgTime :: ZonedTime -> StyledString +ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M" + +ttyMsgContent :: MsgContent -> [StyledString] +ttyMsgContent = \case + MCText t -> msgPlain t + MCUnknown -> ["unknown message type"] + +ttySentFile :: StyledString -> FileTransferId -> FilePath -> [StyledString] +ttySentFile to fId fPath = ["/f " <> to <> ttyFilePath fPath, "use " <> highlight ("/fc " <> show fId) <> " to cancel sending"] prependFirst :: StyledString -> [StyledString] -> [StyledString] prependFirst s [] = [s] @@ -609,21 +372,12 @@ prependFirst s (s' : ss) = (s <> s') : ss msgPlain :: Text -> [StyledString] msgPlain = map styleMarkdownText . T.lines -sentFileInfo :: Int64 -> [StyledString] -sentFileInfo fileId = - ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"] +viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] +viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = + [ttyContact c <> " cancelled sending " <> rcvFile ft] -sndFileStart :: SndFileTransfer -> [StyledString] -sndFileStart = sendingFile_ "started" - -sndFileComplete :: SndFileTransfer -> [StyledString] -sndFileComplete = sendingFile_ "completed" - -sndFileCancelled :: SndFileTransfer -> [StyledString] -sndFileCancelled = sendingFile_ "cancelled" - -sndGroupFileCancelled :: [SndFileTransfer] -> [StyledString] -sndGroupFileCancelled fts = +viewSndGroupFileCancelled :: [SndFileTransfer] -> [StyledString] +viewSndGroupFileCancelled fts = case filter (\SndFileTransfer {fileStatus = s} -> s /= FSCancelled && s /= FSComplete) fts of [] -> ["sending file can't be cancelled"] ts@(ft : _) -> ["cancelled sending " <> sndFile ft <> " to " <> listMembers ts] @@ -632,15 +386,14 @@ sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString] sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = [status <> " sending " <> sndFile ft <> " to " <> ttyContact c] -sndFileRcvCancelled :: SndFileTransfer -> [StyledString] -sndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} = - [ttyContact c <> " cancelled receiving " <> sndFile ft] - sndFile :: SndFileTransfer -> StyledString -sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName +sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -receivedFileInvitation :: RcvFileTransfer -> [StyledString] -receivedFileInvitation RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = +viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString] +viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) + +receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] +receivedFileInvitation_ RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = [ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)", "use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it" ] @@ -657,35 +410,18 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -rcvFileAccepted :: RcvFileTransfer -> FilePath -> [StyledString] -rcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath = - ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] - -rcvFileStart :: RcvFileTransfer -> [StyledString] -rcvFileStart = receivingFile_ "started" - -rcvFileComplete :: RcvFileTransfer -> [StyledString] -rcvFileComplete = receivingFile_ "completed" - -rcvFileCancelled :: RcvFileTransfer -> [StyledString] -rcvFileCancelled = receivingFile_ "cancelled" - receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = [status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c] -rcvFileSndCancelled :: RcvFileTransfer -> [StyledString] -rcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = - [ttyContact c <> " cancelled sending " <> rcvFile ft] - rcvFile :: RcvFileTransfer -> StyledString -rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransfer fileId fileName +rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransferStr fileId fileName -fileTransfer :: Int64 -> String -> StyledString -fileTransfer fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath fileName <> ")" +fileTransferStr :: Int64 -> String -> StyledString +fileTransferStr fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath fileName <> ")" -fileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString] -fileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) = +viewFileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString] +viewFileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) = ["sending " <> sndFile ft <> " " <> sndStatus] where sndStatus = case fileStatus of @@ -694,8 +430,8 @@ fileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}] FSConnected -> "progress " <> fileProgress chunksNum chunkSize fileSize FSComplete -> "complete" FSCancelled -> "cancelled" -fileTransferStatus (FTSnd [], _) = ["no file transfers (empty group)"] -fileTransferStatus (FTSnd fts@(ft : _), chunksNum) = +viewFileTransferStatus (FTSnd [], _) = ["no file transfers (empty group)"] +viewFileTransferStatus (FTSnd fts@(ft : _), chunksNum) = case concatMap membersTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of [membersStatus] -> ["sending " <> sndFile ft <> " " <> membersStatus] membersStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) membersStatuses @@ -710,7 +446,7 @@ fileTransferStatus (FTSnd fts@(ft : _), chunksNum) = FSConnected -> "in progress (" <> sShow (sum chunksNum * chunkSize * 100 `div` (toInteger (length chunksNum) * fileSize)) <> "%)" FSComplete -> "complete" FSCancelled -> "cancelled" -fileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, fileStatus, chunkSize}, chunksNum) = +viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, fileStatus, chunkSize}, chunksNum) = ["receiving " <> rcvFile ft <> " " <> rcvStatus] where rcvStatus = case fileStatus of @@ -727,25 +463,25 @@ fileProgress :: [Integer] -> Integer -> Integer -> StyledString fileProgress chunksNum chunkSize fileSize = sShow (sum chunksNum * chunkSize * 100 `div` fileSize) <> "% of " <> humanReadableSize fileSize -sndFileSubError :: SndFileTransfer -> ChatError -> [StyledString] -sndFileSubError SndFileTransfer {fileId, fileName} e = - ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - -rcvFileSubError :: RcvFileTransfer -> ChatError -> [StyledString] -rcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e = - ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - -chatError :: ChatError -> [StyledString] -chatError = \case +viewChatError :: ChatError -> [StyledString] +viewChatError = \case ChatError err -> case err of + CENoActiveUser -> ["error: active user is required"] + CEActiveUserExists -> ["error: active user already exists"] + CEChatNotStarted -> ["error: chat not started"] + CEInvalidConnReq -> viewInvalidConnReq + CEInvalidChatMessage e -> ["chat message error: " <> sShow e] + CEContactGroups Contact {localDisplayName} gNames -> [ttyContact localDisplayName <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames] 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"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] - CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> g)] + CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)] CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"] CEGroupMemberUserRemoved -> ["you are no longer a member of the group"] CEGroupMemberNotFound c -> ["contact " <> ttyContact c <> " is not a group member"] + CEGroupMemberIntroNotFound c -> ["group member intro not found for " <> ttyContact c] + CEGroupCantResendInvitation g c -> viewCannotResendInvitation g c CEGroupInternal s -> ["chat group bug: " <> plain s] CEFileNotFound f -> ["file not found: " <> plain f] CEFileAlreadyReceiving f -> ["file is already accepted: " <> plain f] @@ -756,33 +492,34 @@ chatError = \case CEFileRcvChunk e -> ["error receiving file: " <> plain e] CEFileInternal e -> ["file error: " <> plain e] CEAgentVersion -> ["unsupported agent version"] + CECommandError e -> ["bad chat command: " <> plain e] -- e -> ["chat error: " <> sShow e] ChatErrorStore err -> case err of SEDuplicateName -> ["this display name is already used by user, contact or group"] - SEContactNotFound c -> ["no contact " <> ttyContact c] + SEContactNotFoundByName c -> ["no contact " <> ttyContact c] SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"] - SEGroupNotFound g -> ["no group " <> ttyGroup g] + SEGroupNotFoundByName g -> ["no group " <> ttyGroup g] SEGroupAlreadyJoined -> ["you already joined this group"] SEFileNotFound fileId -> fileNotFound fileId SESndFileNotFound fileId -> fileNotFound fileId SERcvFileNotFound fileId -> fileNotFound fileId SEDuplicateContactLink -> ["you already have chat address, to show: " <> highlight' "/sa"] SEUserContactLinkNotFound -> ["no chat address, to create: " <> highlight' "/ad"] - SEContactRequestNotFound c -> ["no contact request from " <> ttyContact c] + SEContactRequestNotFoundByName c -> ["no contact request from " <> ttyContact c] + SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity e -> ["chat db error: " <> sShow e] ChatErrorAgent err -> case err of SMP SMP.AUTH -> ["error: this connection is deleted"] e -> ["smp agent error: " <> sShow e] - ChatErrorMessage e -> ["chat message error: " <> sShow e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] -printToView :: (MonadUnliftIO m, MonadReader ChatController m) => [StyledString] -> m () -printToView s = asks chatTerminal >>= liftIO . (`printToTerminal` s) - ttyContact :: ContactName -> StyledString ttyContact = styled (Colored Green) +ttyContact' :: Contact -> StyledString +ttyContact' Contact {localDisplayName = c} = ttyContact c + ttyFullContact :: Contact -> StyledString ttyFullContact Contact {localDisplayName, profile = Profile {fullName}} = ttyFullName localDisplayName fullName @@ -806,15 +543,23 @@ ttyFromContact c = styled (Colored Yellow) $ c <> "> " ttyGroup :: GroupName -> StyledString ttyGroup g = styled (Colored Blue) $ "#" <> g -ttyFullGroup :: Group -> StyledString -ttyFullGroup Group {localDisplayName, groupProfile = GroupProfile {fullName}} = - ttyGroup localDisplayName <> optFullName localDisplayName fullName +ttyGroup' :: GroupInfo -> StyledString +ttyGroup' = ttyGroup . groupName' -ttyFromGroup :: GroupName -> ContactName -> StyledString -ttyFromGroup g c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> " +ttyGroups :: [GroupName] -> StyledString +ttyGroups [] = "" +ttyGroups [g] = ttyGroup g +ttyGroups (g : gs) = ttyGroup g <> ", " <> ttyGroups gs -ttyToGroup :: GroupName -> StyledString -ttyToGroup g = styled (Colored Cyan) $ "#" <> g <> " " +ttyFullGroup :: GroupInfo -> StyledString +ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName}} = + ttyGroup g <> optFullName g fullName + +ttyFromGroup :: GroupInfo -> ContactName -> StyledString +ttyFromGroup GroupInfo {localDisplayName = g} c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> " + +ttyToGroup :: GroupInfo -> StyledString +ttyToGroup GroupInfo {localDisplayName = g} = styled (Colored Cyan) $ "#" <> g <> " " ttyFilePath :: FilePath -> StyledString ttyFilePath = plain @@ -832,6 +577,3 @@ highlight' = highlight styleTime :: String -> StyledString styleTime = Styled [SetColor Foreground Vivid Black] - -clientVersionInfo :: [StyledString] -clientVersionInfo = [plain versionStr, plain updateStr] diff --git a/stack.yaml b/stack.yaml index d17f2317df..4b16aa4f0c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -37,17 +37,23 @@ packages: extra-deps: - cryptostore-0.2.1.0@sha256:9896e2984f36a1c8790f057fd5ce3da4cbcaf8aa73eb2d9277916886978c5b19,3881 - simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079 - # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - - github: simplex-chat/haskell-terminal - commit: 5e0759ce4f9655fd3f0d94c76225e6904630dfd3 - - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 + - tls-1.5.7@sha256:1cc30253a9696b65a9cafc0317fbf09f7dcea15e3a145ed6c9c0e28c632fa23a,6991 + # below hackage dependancies are to update Aeson to 2.0.3 + - OneTuple-0.3.1@sha256:a848c096c9d29e82ffdd30a9998aa2931cbccb3a1bc137539d80f6174d31603e,2262 + - attoparsec-0.14.4@sha256:79584bdada8b730cb5138fca8c35c76fbef75fc1d1e01e6b1d815a5ee9843191,5810 + - hashable-1.4.0.2@sha256:0cddd0229d1aac305ea0404409c0bbfab81f075817bd74b8b2929eff58333e55,5005 + - semialign-1.2.0.1@sha256:0e179b4d3a8eff79001d374d6c91917c6221696b9620f0a4d86852fc6a9b9501,2836 + - text-short-0.1.5@sha256:962c6228555debdc46f758d0317dea16e5240d01419b42966674b08a5c3d8fa6,3498 + - time-compat-1.9.6.1@sha256:42d8f2e08e965e1718917d54ad69e1d06bd4b87d66c41dc7410f59313dba4ed1,5033 + # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - # - github: simplex-chat/simplexmq - # commit: bfa4911217b71527a6fbaf73b242b5684aaf9fce - - github: simplex-chat/hs-tls - commit: cea6d52c512716ff09adcac86ebc95bb0b3bb797 - subdirs: - - core + - github: simplex-chat/simplexmq + commit: c380c795600b887fcae1614a52fb5cda691b569d + # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 + - github: simplex-chat/aeson + commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 + - github: simplex-chat/haskell-terminal + commit: f708b00009b54890172068f168bf98508ffcd495 # # extra-deps: [] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d4fde905b1..b16a74daba 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -18,6 +18,8 @@ import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) import Simplex.Chat.Options import Simplex.Chat.Store +import Simplex.Chat.Terminal +import Simplex.Chat.Terminal.Output (newChatTerminal) import Simplex.Chat.Types (Profile) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -28,6 +30,7 @@ import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) import qualified System.Terminal as C import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) import System.Timeout (timeout) +import Test.Hspec (Expectation, shouldReturn) testDBPrefix :: FilePath testDBPrefix = "tests/tmp/test" @@ -38,8 +41,9 @@ serverPort = "5001" opts :: ChatOpts opts = ChatOpts - { dbFile = undefined, - smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"] + { dbFilePrefix = undefined, + smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], + logging = False } termSettings :: VirtualTerminalSettings @@ -66,16 +70,18 @@ cfg :: ChatConfig cfg = defaultChatConfig { agentConfig = - aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}} + aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}}, + testView = True } virtualSimplexChat :: FilePath -> Profile -> IO TestCC -virtualSimplexChat dbFile profile = do - st <- createStore (dbFile <> "_chat.db") 1 - void . runExceptT $ createUser st profile True +virtualSimplexChat dbFilePrefix profile = do + st <- createStore (dbFilePrefix <> "_chat.db") 1 False + Right user <- runExceptT $ createUser st profile True t <- withVirtualTerminal termSettings pure - cc <- newChatController cfg opts {dbFile} t . const $ pure () -- no notifications - chatAsync <- async $ runSimplexChat cc + ct <- newChatTerminal t + cc <- newChatController st (Just user) cfg opts {dbFilePrefix} (const $ pure ()) -- no notifications + chatAsync <- async $ runSimplexChat user ct cc termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ} @@ -103,19 +109,28 @@ readTerminalOutput t termQ = do then map (dropWhileEnd (== ' ')) diff else getDiff_ (n + 1) len win' win -testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO () -testChatN ps test = +withTmpFiles :: IO () -> IO () +withTmpFiles = bracket_ (createDirectoryIfMissing False "tests/tmp") (removeDirectoryRecursive "tests/tmp") - $ do - let envs = zip ps $ map ((testDBPrefix <>) . show) [(1 :: Int) ..] - tcs <- getTestCCs envs [] - test tcs + +testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO () +testChatN ps test = withTmpFiles $ do + let envs = zip ps $ map ((testDBPrefix <>) . show) [(1 :: Int) ..] + tcs <- getTestCCs envs [] + test tcs + concurrentlyN_ $ map ( virtualSimplexChat db p <*> getTestCCs envs' tcs +( Int -> Expectation +( IO String +getTermLine = atomically . readTQueue . termQ + testChat2 :: Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () testChat2 p1 p2 test = testChatN [p1, p2] test_ where diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 2a4d4e05b5..d08f99b983 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -9,12 +10,12 @@ import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM import qualified Data.ByteString as B import Data.Char (isDigit) +import Data.Maybe (fromJust) import qualified Data.Text as T -import Simplex.Chat.Controller +import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Types (Profile (..), User (..)) import Simplex.Chat.Util (unlessM) import System.Directory (doesFileExist) -import System.Timeout (timeout) import Test.Hspec aliceProfile :: Profile @@ -65,10 +66,31 @@ testAddContact = concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - alice #> "@bob hello" - bob <# "alice> hello" + -- empty chats + alice #$$> ("/_get chats", [("@bob", "")]) + alice #$> ("/_get chat @2 count=100", chat, []) + bob #$$> ("/_get chats", [("@alice", "")]) + bob #$> ("/_get chat @2 count=100", chat, []) + -- one message + alice #> "@bob hello 🙂" + bob <# "alice> hello 🙂" + alice #$$> ("/_get chats", [("@bob", "hello 🙂")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂")]) + bob #$$> ("/_get chats", [("@alice", "hello 🙂")]) + bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂")]) + -- many messages bob #> "@alice hi" alice <# "bob> hi" + alice #$$> ("/_get chats", [("@bob", "hi")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂"), (0, "hi")]) + bob #$$> ("/_get chats", [("@alice", "hi")]) + bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂"), (1, "hi")]) + -- pagination + alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")]) + alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello 🙂")]) + -- read messages + alice #$> ("/_read chat @2 from=1 to=100", id, "ok") + bob #$> ("/_read chat @2 from=1 to=100", id, "ok") -- test adding the same contact one more time - local name will be different alice ##> "/c" inv' <- getInvitation alice @@ -81,11 +103,15 @@ testAddContact = bob <# "alice_1> hello" bob #> "@alice_1 hi" alice <# "bob_1> hi" + alice #$$> ("/_get chats", [("@bob_1", "hi"), ("@bob", "hi")]) + bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" - alice #> "@bob_1 hey" + alice ##> "@bob_1 hey" alice <## "no contact bob_1" + alice #$$> ("/_get chats", [("@bob", "hi")]) + bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) testGroup :: IO () testGroup = @@ -132,11 +158,23 @@ testGroup = concurrently_ (alice <# "#team bob> hi there") (cath <# "#team bob> hi there") - cath #> "#team hey" + cath #> "#team hey team" concurrently_ - (alice <# "#team cath> hey") - (bob <# "#team cath> hey") + (alice <# "#team cath> hey team") + (bob <# "#team cath> hey team") bob <##> cath + -- get and read chats + alice #$$> ("/_get chats", [("#team", "hey team"), ("@cath", ""), ("@bob", "")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")]) + alice #$> ("/_get chat #1 after=1 count=100", chat, [(0, "hi there"), (0, "hey team")]) + alice #$> ("/_get chat #1 before=3 count=100", chat, [(1, "hello"), (0, "hi there")]) + bob #$$> ("/_get chats", [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")]) + bob #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (1, "hi there"), (0, "hey team")]) + cath #$$> ("/_get chats", [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")]) + cath #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (0, "hi there"), (1, "hey team")]) + alice #$> ("/_read chat #1 from=1 to=100", id, "ok") + bob #$> ("/_read chat #1 from=1 to=100", id, "ok") + cath #$> ("/_read chat #1 from=1 to=100", id, "ok") -- list groups alice ##> "/gs" alice <## "#team" @@ -168,7 +206,7 @@ testGroup = concurrently_ (bob <# "#team alice> hello") (cath "#team hello" + cath ##> "#team hello" cath <## "you are no longer a member of the group" bob <##> cath @@ -293,7 +331,7 @@ testGroup2 = bob <# "#club cath> hey", (dan "#club how is it going?" + dan ##> "#club how is it going?" dan <## "you are no longer a member of the group" dan ##> "/d #club" dan <## "#club: you deleted the group" @@ -316,7 +354,7 @@ testGroup2 = concurrently_ (alice <# "#club cath> hey") (bob "#club how is it going?" + bob ##> "#club how is it going?" bob <## "you are no longer a member of the group" bob ##> "/d #club" bob <## "#club: you deleted the group" @@ -338,9 +376,11 @@ testGroupDelete = cath <## "#team: alice deleted the group" cath <## "use /d #team to delete the local copy of the group" ] + alice ##> "#team hi" + alice <## "no group #team" bob ##> "/d #team" bob <## "#team: you deleted the group" - cath #> "#team hi" + cath ##> "#team hi" cath <## "you are no longer a member of the group" cath ##> "/d #team" cath <## "#team: you deleted the group" @@ -658,20 +698,24 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob + alice #$$> ("/_get chats", [("<@bob", "")]) alice ##> "/ac bob" alice <## "bob: accepting contact request..." concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + alice #$$> ("/_get chats", [("@bob", "")]) alice <##> bob cath ##> ("/c " <> cLink) alice <#? cath + alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) alice ##> "/ac cath" alice <## "cath: accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) alice <##> cath testRejectContactAndDeleteUserContact :: IO () @@ -751,7 +795,7 @@ connectUsers cc1 cc2 = do showName :: TestCC -> IO String showName (TestCC ChatController {currentUser} _ _ _ _) = do - User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser + Just User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")" createGroup2 :: String -> TestCC -> TestCC -> IO () @@ -809,7 +853,7 @@ cc1 <##> cc2 = do cc1 <# (name2 <> "> hey") userName :: TestCC -> IO [Char] -userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName <$> readTVarIO currentUser +userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser (##>) :: TestCC -> String -> IO () cc ##> cmd = do @@ -821,8 +865,23 @@ cc #> cmd = do cc `send` cmd cc <# cmd +(#$>) :: (Eq a, Show a) => TestCC -> (String, String -> a, a) -> Expectation +cc #$> (cmd, f, res) = do + cc ##> cmd + (f <$> getTermLine cc) `shouldReturn` res + +chat :: String -> [(Int, String)] +chat = read + +(#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation +cc #$$> (cmd, res) = do + cc ##> cmd + line <- getTermLine cc + let chats = read line + chats `shouldMatchList` res + send :: TestCC -> String -> IO () -send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) $ InputCommand cmd +send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cmd (<##) :: TestCC -> String -> Expectation cc <## line = getTermLine cc `shouldReturn` line @@ -839,7 +898,7 @@ cc <### ls = do cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line ( Expectation -( TestCC -> Expectation cc1 <#? cc2 = do @@ -856,9 +915,6 @@ dropTime msg = case splitAt 6 msg of if all isDigit [m, m', s, s'] then text else error "invalid time" _ -> error "invalid time" -getTermLine :: TestCC -> IO String -getTermLine = atomically . readTQueue . termQ - getInvitation :: TestCC -> IO String getInvitation cc = do cc <## "pass this invitation link to your contact (via another channel):" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs new file mode 100644 index 0000000000..f48cf71312 --- /dev/null +++ b/tests/MobileTests.hs @@ -0,0 +1,43 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module MobileTests where + +import ChatClient +import ChatTests +import Control.Monad.Except +import Simplex.Chat.Mobile +import Simplex.Chat.Store +import Test.Hspec + +mobileTests :: Spec +mobileTests = do + describe "mobile API" $ do + it "start new chat without user" testChatApiNoUser + it "start new chat with existing user" testChatApi + +noActiveUser :: String +noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}" + +activeUserExists :: String +activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"activeUserExists\":{}}}}}}}" + +activeUser :: String +activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}" + +testChatApiNoUser :: IO () +testChatApiNoUser = withTmpFiles $ do + cc <- chatInit testDBPrefix + chatSendCmd cc "/u" `shouldReturn` noActiveUser + chatSendCmd cc "/_start" `shouldReturn` noActiveUser + chatSendCmd cc "/u alice Alice" `shouldReturn` activeUser + chatSendCmd cc "/_start" `shouldReturn` "{\"resp\":{\"chatStarted\":{}}}" + +testChatApi :: IO () +testChatApi = withTmpFiles $ do + let f = chatStoreFile testDBPrefix + st <- createStore f 1 True + Right _ <- runExceptT $ createUser st aliceProfile True + cc <- chatInit testDBPrefix + chatSendCmd cc "/u" `shouldReturn` activeUser + chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists + chatSendCmd cc "/_start" `shouldReturn` "{\"resp\":{\"chatStarted\":{}}}" diff --git a/tests/Test.hs b/tests/Test.hs index 961475ab38..8ed0ac0dcb 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,6 +1,7 @@ import ChatClient import ChatTests import MarkdownTests +import MobileTests import ProtocolTests import Test.Hspec @@ -8,4 +9,5 @@ main :: IO () main = withSmpServer . hspec $ do describe "SimpleX chat markdown" markdownTests describe "SimpleX chat protocol" protocolTests + describe "Mobile API Tests" mobileTests describe "SimpleX chat client" chatTests diff --git a/update-sha256.awk b/update-sha256.awk new file mode 100644 index 0000000000..e432ec32d2 --- /dev/null +++ b/update-sha256.awk @@ -0,0 +1,23 @@ +BEGIN { + print "{" + loc="" + ref="" + isGit=false +} +/source-repository-package/ { loc=""; ref=""; isGit=false; } + +/type: git/ { isGit=true; } +/location/ && isGit == true { loc=$2 } +/tag/ && isGit == true { ref=$2 } + +isGit == true && loc != "" && ref != "" { + cmd = "nix-prefetch-git --quiet "loc" "ref" | jq -r .sha256" + cmd | getline sha256 + close(cmd) + print " \""loc"\".\""ref"\" = \""sha256"\";"; + isGit=false; loc=""; ref=""; +} + +END { + print "}" +}