From aef159b0979ef1369b439bbe7c66b6790a8c4c35 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 18 Jan 2022 20:39:16 +0000 Subject: [PATCH 01/82] readme: building from stable branch --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 63f5603c1c..f09e01ee84 100644 --- a/README.md +++ b/README.md @@ -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 ``` From 65b17c9d18e9856c8041b9a8acc7cd5f0c48915e Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Thu, 20 Jan 2022 12:18:00 +0400 Subject: [PATCH 02/82] add option to enable logging (#216) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 16 ++++++++++------ src/Simplex/Chat/Options.hs | 8 +++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4e4d1e41bc..c7241bf7ed 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -127,12 +127,16 @@ 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 +simplexChat cfg opts@ChatOpts {logging} t + | logging = do + setLogLevel LogInfo -- LogError + withGlobalLogging logCfg initRun + | otherwise = initRun + where + initRun = + 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 diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index b0544c4f35..f7504aabda 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -15,7 +15,8 @@ import System.FilePath (combine) data ChatOpts = ChatOpts { dbFile :: String, - smpServers :: NonEmpty SMPServer + smpServers :: NonEmpty SMPServer, + logging :: Bool } chatOpts :: FilePath -> Parser ChatOpts @@ -45,6 +46,11 @@ chatOpts appDir = ] ) ) + <*> switch + ( long "log" + <> short 'l' + <> help "Enable logging" + ) where defaultDbFilePath = combine appDir "simplex_v1" From 32a105bac85e16a4126491206dcac4ef67fbac31 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 21 Jan 2022 00:19:39 +0400 Subject: [PATCH 03/82] fix Windows CI to fail when commands fail, use fixed terminal package (#214) * fix windows CI * use fixed terminal package Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .github/workflows/build.yml | 55 ++++++++++++++++++++++++++----------- cabal.project | 2 +- stack.yaml | 2 +- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ab917a6db..6590ac2b10 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: @@ -49,24 +49,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 +76,49 @@ jobs: path: ${{ matrix.cache_path }} key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }} - - name: Build & test - id: build_test + # / Unix + + - name: Unix build + id: unix_build + if: matrix.os != 'windows-latest' + shell: bash run: | - stack build ${{ matrix.stack_args }} + stack build --test echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)" - - name: Upload binaries to release - if: startsWith(github.ref, 'refs/tags/v') + - 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 + echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --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/cabal.project b/cabal.project index 8422063751..2ba37ee93c 100644 --- a/cabal.project +++ b/cabal.project @@ -9,4 +9,4 @@ source-repository-package source-repository-package type: git location: git://github.com/simplex-chat/haskell-terminal.git - tag: 5e0759ce4f9655fd3f0d94c76225e6904630dfd3 + tag: f708b00009b54890172068f168bf98508ffcd495 diff --git a/stack.yaml b/stack.yaml index d17f2317df..e7e09510eb 100644 --- a/stack.yaml +++ b/stack.yaml @@ -39,7 +39,7 @@ extra-deps: - simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/haskell-terminal - commit: 5e0759ce4f9655fd3f0d94c76225e6904630dfd3 + commit: f708b00009b54890172068f168bf98508ffcd495 - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq # - github: simplex-chat/simplexmq From f47494e5c8bdc45517e57457cc65cc08ca40a902 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 20 Jan 2022 20:23:21 +0000 Subject: [PATCH 04/82] add loggin option to test --- tests/ChatClient.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d4fde905b1..3e351a35e3 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -39,7 +39,8 @@ opts :: ChatOpts opts = ChatOpts { dbFile = undefined, - smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"] + smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], + logging = False } termSettings :: VirtualTerminalSettings From 64381be91d2398641a2ec602374a27daeff8248a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 21 Jan 2022 11:09:33 +0000 Subject: [PATCH 05/82] export C interface, started mobile app (#210) * initial mobile app design draft * add proposals * xcode project * refactor function to send to view as parameter * export C interface * remove unused files * run chat from chatInit * split chatStart to a separate function * replace file-embed with QQ * add mobile views * server using IP address * pass dbFilePrefix as parameter to chatInit * comment on enabling logging * fix mobile db config * update C API, make user non-optional in ChatController * restore SMP server addresses * revert the change in the tests * flip dependency - now Controller depends on Terminal * make ChatController independent of terminal package * fix Main.hs * add iOS .gitignore * refactor Simplex.Chat.Terminal Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> --- apps/ios/.gitignore | 65 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 148 ++++ apps/ios/Shared/Assets.xcassets/Contents.json | 6 + apps/ios/Shared/ContentView.swift | 50 ++ apps/ios/Shared/MessageView.swift | 34 + apps/ios/Shared/ProfileView.swift | 32 + apps/ios/Shared/SimpleXApp.swift | 17 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 720 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + apps/ios/Tests iOS/Tests_iOS.swift | 42 + apps/ios/Tests iOS/Tests_iOSLaunchTests.swift | 32 + apps/ios/Tests macOS/Tests_macOS.swift | 42 + .../Tests macOS/Tests_macOSLaunchTests.swift | 32 + apps/ios/macOS/macOS.entitlements | 10 + apps/simplex-chat/Main.hs | 5 +- cabal.project | 7 +- package.yaml | 2 - rfcs/2022-01-26-mobile-app.md | 167 ++++ simplex-chat.cabal | 11 +- src/Simplex/Chat.hs | 255 +++---- src/Simplex/Chat/Controller.hs | 14 +- .../Chat/Migrations/M20220101_initial.hs | 11 + src/Simplex/Chat/Mobile.hs | 126 +++ src/Simplex/Chat/Options.hs | 26 +- src/Simplex/Chat/Store.hs | 19 +- src/Simplex/Chat/Styled.hs | 5 + src/Simplex/Chat/Terminal.hs | 200 +---- src/Simplex/Chat/{ => Terminal}/Input.hs | 16 +- .../Chat/{ => Terminal}/Notification.hs | 5 +- src/Simplex/Chat/Terminal/Output.hs | 179 +++++ src/Simplex/Chat/Types.hs | 5 + src/Simplex/Chat/View.hs | 656 ++++++---------- stack.yaml | 8 +- tests/ChatClient.hs | 15 +- 36 files changed, 2211 insertions(+), 777 deletions(-) create mode 100644 apps/ios/.gitignore create mode 100644 apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/Shared/ContentView.swift create mode 100644 apps/ios/Shared/MessageView.swift create mode 100644 apps/ios/Shared/ProfileView.swift create mode 100644 apps/ios/Shared/SimpleXApp.swift create mode 100644 apps/ios/SimpleX.xcodeproj/project.pbxproj create mode 100644 apps/ios/SimpleX.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 apps/ios/Tests iOS/Tests_iOS.swift create mode 100644 apps/ios/Tests iOS/Tests_iOSLaunchTests.swift create mode 100644 apps/ios/Tests macOS/Tests_macOS.swift create mode 100644 apps/ios/Tests macOS/Tests_macOSLaunchTests.swift create mode 100644 apps/ios/macOS/macOS.entitlements create mode 100644 rfcs/2022-01-26-mobile-app.md rename migrations/20220101_initial.sql => src/Simplex/Chat/Migrations/M20220101_initial.hs (97%) create mode 100644 src/Simplex/Chat/Mobile.hs rename src/Simplex/Chat/{ => Terminal}/Input.hs (92%) rename src/Simplex/Chat/{ => Terminal}/Notification.hs (96%) create mode 100644 src/Simplex/Chat/Terminal/Output.hs diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000000..195fd5ee74 --- /dev/null +++ b/apps/ios/.gitignore @@ -0,0 +1,65 @@ +## 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/ 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..eb87897008 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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..c136eaff76 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,148 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "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/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/ContentView.swift b/apps/ios/Shared/ContentView.swift new file mode 100644 index 0000000000..c560668723 --- /dev/null +++ b/apps/ios/Shared/ContentView.swift @@ -0,0 +1,50 @@ +// +// ContentView.swift +// Shared +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import SwiftUI + +struct ContentView: View { + @State var messages: [String] = ["Start session:"] + @State var text: String = "" + + func sendMessage() { + } + + var body: some View { + VStack { + ScrollView { + LazyVStack { + ForEach(messages, id: \.self) { msg in + MessageView(message: msg, sent: false) + } + } + .padding(10) + } + .frame(minWidth: 0, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: .topLeading) + HStack { + TextField("Message...", text: $text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(minHeight: CGFloat(30)) + Button(action: sendMessage) { + Text("Send") + }.disabled(text.isEmpty) + } + .frame(minHeight: CGFloat(30)) + .padding() + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView(text: "Hello!") + } +} diff --git a/apps/ios/Shared/MessageView.swift b/apps/ios/Shared/MessageView.swift new file mode 100644 index 0000000000..76ebbc3341 --- /dev/null +++ b/apps/ios/Shared/MessageView.swift @@ -0,0 +1,34 @@ +// +// MessageView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 18/01/2022. +// + +import SwiftUI + +struct MessageView: View { + var message: String + var sent: Bool + let receivedColor: Color = Color(UIColor(red: 240/255, green: 240/255, blue: 240/255, alpha: 1.0)) + + var body: some View { + Text(message) + .padding(10) + .foregroundColor(sent ? Color.white : Color.black) + .background(sent ? Color.blue : receivedColor) + .cornerRadius(10) + .frame(minWidth: 100, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: .leading) + + } +} + +struct MessageView_Previews: PreviewProvider { + static var previews: some View { + MessageView(message: "> Send message: \"Hello world!\"\nSuccessful", sent: false) + } +} diff --git a/apps/ios/Shared/ProfileView.swift b/apps/ios/Shared/ProfileView.swift new file mode 100644 index 0000000000..a27f1ec9e2 --- /dev/null +++ b/apps/ios/Shared/ProfileView.swift @@ -0,0 +1,32 @@ +// +// ProfileView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 18/01/2022. +// + +import SwiftUI + +struct ProfileView: View { + @State var displayName: String = "" + @State var fullName: String = "" + var body: some View { + VStack(alignment: .leading) { + Text("Create profile") + .font(.largeTitle) + .padding(.bottom) + Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.") + .padding(.bottom) + TextField("Display name", text: $displayName) + .padding(.bottom) + TextField("Full name (optional)", text: $fullName) + } + .padding() + } +} + +struct ProfileView_Previews: PreviewProvider { + static var previews: some View { + ProfileView() + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift new file mode 100644 index 0000000000..3598b4284a --- /dev/null +++ b/apps/ios/Shared/SimpleXApp.swift @@ -0,0 +1,17 @@ +// +// SimpleXApp.swift +// Shared +// +// Created by Evgeny Poberezkin on 17/01/2022. +// + +import SwiftUI + +@main +struct SimpleXApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..5a6c43804b --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -0,0 +1,720 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 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 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */; }; + 5CA05A4D27974EB60002BEB4 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */; }; + 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; + 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.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 */ + 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; }; + 5CA059D2279559F40002BEB4 /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; + 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 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + 5CA05A4E279752D00002BEB4 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5CA059C7279559F40002BEB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059CD279559F40002BEB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + 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 */ + 5CA059BD279559F40002BEB4 = { + isa = PBXGroup; + children = ( + 5CA059C2279559F40002BEB4 /* Shared */, + 5CA059D1279559F40002BEB4 /* macOS */, + 5CA059DA279559F40002BEB4 /* Tests iOS */, + 5CA059E6279559F40002BEB4 /* Tests macOS */, + 5CA059CB279559F40002BEB4 /* Products */, + ); + sourceTree = ""; + }; + 5CA059C2279559F40002BEB4 /* Shared */ = { + isa = PBXGroup; + children = ( + 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, + 5CA059C4279559F40002BEB4 /* ContentView.swift */, + 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */, + 5CA05A4E279752D00002BEB4 /* MessageView.swift */, + 5CA059C5279559F40002BEB4 /* Assets.xcassets */, + ); + 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 = ( + 5CA059D2279559F40002BEB4 /* macOS.entitlements */, + ); + 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 = ""; + }; +/* 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)"; + 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; + TargetAttributes = { + 5CA059C9279559F40002BEB4 = { + CreatedOnToolsVersion = 13.2.1; + }; + 5CA059CF279559F40002BEB4 = { + CreatedOnToolsVersion = 13.2.1; + }; + 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; + 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 = ( + 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */, + 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, + 5CA05A4C27974EB60002BEB4 /* ProfileView.swift in Sources */, + 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CA059CC279559F40002BEB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */, + 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, + 5CA05A4D27974EB60002BEB4 /* ProfileView.swift in Sources */, + 5CA059EC279559F40002BEB4 /* SimpleXApp.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; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + PRODUCT_NAME = SimpleX; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + 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; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + PRODUCT_NAME = SimpleX; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + 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; + CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + PRODUCT_NAME = SimpleX; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 5CA059F8279559F40002BEB4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9767FTRA3G; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + PRODUCT_NAME = SimpleX; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + 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 */ + }; + 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/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 2ba37ee93c..38cea86b8d 100644 --- a/cabal.project +++ b/cabal.project @@ -1,9 +1,14 @@ packages: . +source-repository-package + type: git + location: git://github.com/simplex-chat/simplexmq.git + tag: 670b3b79749bfb48a04ee40b8c441e9ca68ad41a + source-repository-package type: git location: git://github.com/simplex-chat/hs-tls.git - tag: cea6d52c512716ff09adcac86ebc95bb0b3bb797 + tag: f6cc753611f80af300401cfae63846e9d7c40d9e subdir: core source-repository-package diff --git a/package.yaml b/package.yaml index d8bfde04b2..68b2df3f6e 100644 --- a/package.yaml +++ b/package.yaml @@ -10,7 +10,6 @@ copyright: 2020-22 simplex.chat category: Web, System, Services, Cryptography extra-source-files: - README.md - - migrations/*.* dependencies: - aeson == 1.5.* @@ -24,7 +23,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 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/simplex-chat.cabal b/simplex-chat.cabal index f63210aed3..0e43bfbf4f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -16,21 +16,23 @@ 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.Migrations.M20220101_initial + 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.Output + Simplex.Chat.Terminal.Notification Simplex.Chat.Types Simplex.Chat.Util Simplex.Chat.View @@ -51,7 +53,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 @@ -87,7 +88,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 @@ -130,7 +130,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.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c7241bf7ed..a54690a2a5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -38,15 +38,12 @@ import Data.Text.Encoding (encodeUtf8) import Data.Word (Word32) import Simplex.Chat.Controller import Simplex.Chat.Help -import Simplex.Chat.Input -import Simplex.Chat.Notification 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.Styled import Simplex.Chat.Types -import Simplex.Chat.Util (ifM, unlessM, whenM) +import Simplex.Chat.Util (ifM, unlessM) import Simplex.Chat.View import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), defaultAgentConfig) @@ -62,7 +59,6 @@ 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.Concurrent (forkIO, threadDelay) import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory) import qualified UnliftIO.Exception as E @@ -126,45 +122,29 @@ defaultChatConfig = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChat cfg opts@ChatOpts {logging} t - | logging = do - setLogLevel LogInfo -- LogError - withGlobalLogging logCfg initRun - | otherwise = initRun - where - initRun = - 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 -> 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} idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize + outputQ <- newTBQueueIO tbqSize notifyQ <- newTBQueueIO tbqSize chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty - pure ChatController {..} - -runSimplexChat :: ChatController -> IO () -runSimplexChat = runReaderT $ do - user <- readTVarIO =<< asks currentUser - whenM (asks firstTime) . printToView $ chatWelcome user - race_ runTerminalInput runChatController + pure ChatController {activeTo, firstTime, currentUser, smpAgent, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} runChatController :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -runChatController = +runChatController = do + q <- asks outputQ + let toView = atomically . writeTBQueue q raceAny_ - [ inputSubscriber, - agentSubscriber, + [ inputSubscriber toView, + agentSubscriber toView, notificationSubscriber ] @@ -174,8 +154,8 @@ withLock lock = (void . atomically $ takeTMVar lock) (atomically $ putTMVar lock ()) -inputSubscriber :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -inputSubscriber = do +inputSubscriber :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => ([StyledString] -> m ()) -> m () +inputSubscriber toView = do q <- asks inputQ l <- asks chatLock a <- asks smpAgent @@ -184,34 +164,36 @@ inputSubscriber = do InputControl _ -> pure () InputCommand s -> case parseAll chatCommandP . B.dropWhileEnd isSpace . encodeUtf8 $ T.pack s of - Left e -> printToView [plain s, "invalid input: " <> plain e] + Left e -> toView [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] + SendMessage c msg -> toView =<< liftIO (viewSentMessage c msg) + SendGroupMessage g msg -> toView =<< liftIO (viewSentGroupMessage g msg) + SendFile c f -> toView =<< liftIO (viewSentFileInvitation c f) + SendGroupFile g f -> toView =<< liftIO (viewSentGroupFileInvitation g f) + _ -> toView [plain s] user <- readTVarIO =<< asks currentUser withAgentLock a . withLock l . void . runExceptT $ - processChatCommand user cmd `catchError` showChatError + processChatCommand toView' user cmd `catchError` (toView' . viewChatError) + where + toView' = ExceptT . fmap Right . toView -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 +processChatCommand :: forall m. ChatMonad m => ([StyledString] -> m ()) -> User -> ChatCommand -> m () +processChatCommand toView user@User {userId, profile} = \case + ChatHelp -> toView chatHelpInfo + FilesHelp -> toView filesHelpInfo + GroupsHelp -> toView groupsHelpInfo + MyAddressHelp -> toView myAddressHelpInfo + MarkdownHelp -> toView markdownInfo + Welcome -> toView $ chatWelcome user AddContact -> 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 + toView $ viewConnReqInvitation cReq + Connect (Just (ACR SCMInvitation cReq)) -> connect cReq (XInfo profile) >> toView viewSentConfirmation + Connect (Just (ACR SCMContact cReq)) -> connect cReq (XContact profile Nothing) >> toView viewSentInvitation + Connect Nothing -> toView viewInvalidConnReq + ConnectAdmin -> connect adminContactReq (XContact profile Nothing) >> toView viewSentInvitation DeleteContact cName -> withStore (\st -> getContactGroupNames st userId cName) >>= \case [] -> do @@ -220,39 +202,39 @@ processChatCommand user@User {userId, profile} = \case 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 + toView $ viewContactDeleted cName + gs -> toView $ viewContactGroups cName gs + ListContacts -> withStore (`getUserContacts` user) >>= toView . viewContactsList CreateMyAddress -> do (connId, cReq) <- withAgent (`createConnection` SCMContact) withStore $ \st -> createUserContactLink st userId connId cReq - showUserContactLinkCreated cReq + toView $ viewUserContactLinkCreated cReq DeleteMyAddress -> 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 + toView viewUserContactLinkDeleted ShowMyAddress -> do cReq <- withStore $ \st -> getUserContactLink st userId - showUserContactLink cReq + toView $ viewUserContactLink 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 + toView $ viewAcceptingContactRequest 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 + toView $ viewContactRequestRejected cName SendMessage cName msg -> sendMessageCmd cName msg NewGroup gProfile -> do gVar <- asks idsDrg group <- withStore $ \st -> createNewGroup st gVar user gProfile - showGroupCreated group + toView $ viewGroupCreated 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 @@ -263,7 +245,7 @@ processChatCommand user@User {userId, profile} = \case let sendInvitation memberId cReq = do sendDirectMessage (contactConn contact) $ XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile - showSentGroupInvitation gName cName + toView $ viewSentGroupInvitation gName cName setActive $ ActiveG gName case contactMember contact members of Nothing -> do @@ -275,7 +257,7 @@ processChatCommand user@User {userId, profile} = \case | memberStatus == GSMemInvited -> withStore (\st -> getMemberInvitation st user groupMemberId) >>= \case Just cReq -> sendInvitation memberId cReq - Nothing -> showCannotResendInvitation gName cName + Nothing -> toView $ viewCannotResendInvitation gName cName | otherwise -> chatError (CEGroupDuplicateMember cName) JoinGroup gName -> do ReceivedGroupInvitation {fromMember, userMember, connRequest} <- withStore $ \st -> getGroupInvitation st user gName @@ -295,13 +277,13 @@ processChatCommand user@User {userId, profile} = \case when (mStatus /= GSMemInvited) . sendGroupMessage members $ XGrpMemDel mId deleteMemberConnection m withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved - showDeletedMember gName Nothing (Just m) + toView $ viewDeletedMember 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 + toView $ viewLeftMemberUser gName DeleteGroup gName -> do g@Group {membership, members} <- withStore $ \st -> getGroup st user gName let s = memberStatus membership @@ -312,11 +294,11 @@ processChatCommand user@User {userId, profile} = \case when (memberActive membership) $ sendGroupMessage members XGrpDel mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g - showGroupDeletedUser gName + toView $ viewGroupDeletedUser gName ListMembers gName -> do group <- withStore $ \st -> getGroup st user gName - showGroupMembers group - ListGroups -> withStore (`getUserGroupDetails` userId) >>= showGroupsList + toView $ viewGroupMembers group + ListGroups -> withStore (`getUserGroupDetails` userId) >>= toView . viewGroupsList SendGroupMessage gName msg -> do -- TODO save pending message delivery for members without connections Group {members, membership} <- withStore $ \st -> getGroup st user gName @@ -332,7 +314,7 @@ processChatCommand user@User {userId, profile} = \case SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize sendDirectMessage (contactConn contact) $ XFile fileInv - showSentFileInfo fileId + toView $ viewSentFileInfo fileId setActive $ ActiveC cName SendGroupFile gName f -> do (fileSize, chSize) <- checkSndFile f @@ -346,7 +328,7 @@ processChatCommand user@User {userId, profile} = \case -- TODO sendGroupMessage - same file invitation to all forM_ ms $ \(m, _, fileInv) -> traverse (`sendDirectMessage` XFile fileInv) $ memberConn m - showSentFileInfo fileId + toView $ viewSentFileInfo fileId setActive $ ActiveG gName ReceiveFile fileId filePath_ -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId @@ -355,29 +337,29 @@ processChatCommand user@User {userId, profile} = \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 + toView $ viewRcvFileAccepted ft filePath + Left (ChatErrorAgent (SMP SMP.AUTH)) -> toView $ viewRcvFileSndCancelled ft + Left (ChatErrorAgent (CONN DUPLICATE)) -> toView $ viewRcvFileSndCancelled ft Left e -> throwError e CancelFile fileId -> withStore (\st -> getFileTransfer st userId fileId) >>= \case FTSnd fts -> do forM_ fts $ \ft -> cancelSndFileTransfer ft - showSndGroupFileCancelled fts + toView $ viewSndGroupFileCancelled fts FTRcv ft -> do cancelRcvFileTransfer ft - showRcvFileCancelled ft + toView $ viewRcvFileCancelled ft FileStatus fileId -> - withStore (\st -> getFileTransferProgress st userId fileId) >>= showFileTransferStatus + withStore (\st -> getFileTransferProgress st userId fileId) >>= toView . viewFileTransferStatus 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 + toView $ viewUserProfileUpdated user user' + ShowProfile -> toView $ viewUserProfile profile QuitChat -> liftIO exitSuccess - ShowVersion -> printToView clientVersionInfo + ShowVersion -> toView clientVersionInfo where connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () connect cReq msg = do @@ -429,19 +411,21 @@ 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, MonadFail m) => ([StyledString] -> m ()) -> m () +agentSubscriber toView = do q <- asks $ subQ . smpAgent l <- asks chatLock - subscribeUserConnections + subscribeUserConnections toView forever $ do (_, connId, msg) <- atomically $ readTBQueue q user <- readTVarIO =<< asks currentUser withLock l . void . runExceptT $ - processAgentMessage user connId msg `catchError` showChatError + processAgentMessage toView' user connId msg `catchError` (toView' . viewChatError) + where + toView' = ExceptT . fmap Right . toView -subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -subscribeUserConnections = void . runExceptT $ do +subscribeUserConnections :: forall m. (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => ([StyledString] -> m ()) -> m () +subscribeUserConnections toView = void . runExceptT $ do user <- readTVarIO =<< asks currentUser subscribeContacts user subscribeGroups user @@ -449,39 +433,40 @@ subscribeUserConnections = void . runExceptT $ do subscribePendingConnections user subscribeUserContactLink user where + toView' = ExceptT . fmap Right . toView subscribeContacts user = do contacts <- withStore (`getUserContacts` user) forM_ contacts $ \ct@Contact {localDisplayName = c} -> - (subscribe (contactConnId ct) >> showContactSubscribed c) `catchError` showContactSubError c + (subscribe (contactConnId ct) >> toView' (viewContactSubscribed c)) `catchError` (toView' . viewContactSubError c) subscribeGroups user = do groups <- withStore (`getUserGroups` user) forM_ groups $ \g@Group {members, membership, localDisplayName = gn} -> do let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members if memberStatus membership == GSMemInvited - then showGroupInvitation g + then toView' $ viewGroupInvitation g else if null connectedMembers then if memberActive membership - then showGroupEmpty g - else showGroupRemoved g + then toView' $ viewGroupEmpty g + else toView' $ viewGroupRemoved g else do forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) -> - subscribe cId `catchError` showMemberSubError gn c - showGroupSubscribed g + subscribe cId `catchError` (toView' . viewMemberSubError gn c) + toView' $ viewGroupSubscribed g subscribeFiles user = do withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile where subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId} = do - subscribe agentConnId `catchError` showSndFileSubError ft + subscribe agentConnId `catchError` (toView' . viewSndFileSubError ft) void . forkIO $ do threadDelay 1000000 l <- asks chatLock a <- asks smpAgent unless (fileStatus == FSNew) . unlessM (isFileActive fileId sndFiles) $ withAgentLock a . withLock l $ - sendFileChunk ft + sendFileChunk toView' ft subscribeRcvFile ft@RcvFileTransfer {fileStatus} = case fileStatus of RFSAccepted fInfo -> resume fInfo @@ -489,22 +474,22 @@ subscribeUserConnections = void . runExceptT $ do _ -> pure () where resume RcvFileInfo {agentConnId} = - subscribe agentConnId `catchError` showRcvFileSubError ft + subscribe agentConnId `catchError` (toView' . viewRcvFileSubError ft) subscribePendingConnections user = do cs <- withStore (`getPendingConnections` user) subscribeConns cs `catchError` \_ -> pure () subscribeUserContactLink User {userId} = do cs <- withStore (`getUserContactLinkConnections` userId) - (subscribeConns cs >> showUserContactLinkSubscribed) - `catchError` showUserContactLinkSubError + (subscribeConns cs >> toView' viewUserContactLinkSubscribed) + `catchError` (toView' . viewUserContactLinkSubError) subscribe cId = withAgent (`subscribeConnection` cId) subscribeConns conns = withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> subscribeConnection a agentConnId -processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agent -> m () -processAgentMessage user@User {userId, profile} agentConnId agentMessage = do +processAgentMessage :: forall m. ChatMonad m => ([StyledString] -> m ()) -> User -> ConnId -> ACommand 'Agent -> m () +processAgentMessage toView 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 @@ -594,7 +579,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do CON -> withStore (\st -> getViaGroupMember st user ct) >>= \case Nothing -> do - showContactConnected ct + toView $ viewContactConnected ct setActive $ ActiveC c showToast (c <> "> ") "connected" Just (gName, m) -> @@ -604,14 +589,14 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do SENT msgId -> sentMsgDeliveryEvent conn msgId END -> do - showContactAnotherClient c + toView $ viewContactAnotherClient c showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c DOWN -> do - showContactDisconnected c + toView $ viewContactDisconnected c showToast (c <> "> ") "disconnected" UP -> do - showContactSubscribed c + toView $ viewContactSubscribed c showToast (c <> "> ") "is active" setActive $ ActiveC c -- TODO print errors @@ -662,11 +647,11 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do -- TODO forward any pending (GMIntroInvReceived) introductions case memberCategory m of GCHostMember -> do - showUserJoinedGroup gName + toView $ viewUserJoinedGroup gName setActive $ ActiveG gName showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do - showJoinedGroupMember gName m + toView $ viewJoinedGroupMember gName m setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" intros <- withStore $ \st -> createIntroductions st group m @@ -723,15 +708,15 @@ 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 - sendFileChunk ft + toView $ viewSndFileStart ft + sendFileChunk toView ft SENT msgId -> do withStore $ \st -> updateSndFileChunkSent st ft msgId - unless (fileStatus == FSCancelled) $ sendFileChunk ft + unless (fileStatus == FSCancelled) $ sendFileChunk toView ft MERR _ err -> do cancelSndFileTransfer ft case err of - SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ showSndFileRcvCancelled ft + SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ viewSndFileRcvCancelled ft _ -> chatError $ CEFileSend fileId err MSG meta _ -> withAckMessage agentConnId meta $ pure () @@ -745,12 +730,12 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do case agentMsg of CON -> do withStore $ \st -> updateRcvFileStatus st ft FSConnected - showRcvFileStart ft + toView $ viewRcvFileStart ft MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case FileChunkCancel -> do cancelRcvFileTransfer ft - showRcvFileSndCancelled ft + toView $ viewRcvFileSndCancelled ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -770,7 +755,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do withStore $ \st -> do updateRcvFileStatus st ft FSComplete deleteRcvFileChunks st ft - showRcvFileComplete ft + toView $ viewRcvFileComplete ft closeFileHandle fileId rcvFiles withAgent (`deleteConnection` agentConnId) RcvChunkDuplicate -> pure () @@ -799,7 +784,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do profileContactRequest :: InvitationId -> Profile -> m () profileContactRequest invId p = do cName <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p - showReceivedContactRequest cName p + toView $ viewReceivedContactRequest cName p showToast (cName <> "> ") "wants to connect to you" withAckMessage :: ConnId -> MsgMeta -> m () -> m () @@ -824,7 +809,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do notifyMemberConnected :: GroupName -> GroupMember -> m () notifyMemberConnected gName m@GroupMember {localDisplayName} = do - showConnectedToGroupMember gName m + toView $ viewConnectedToGroupMember gName m setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName <> " is connected" @@ -842,20 +827,20 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do withStore $ \st -> createSentProbeHash st userId probeId c messageWarning :: Text -> m () - messageWarning = showMessageError "warning" + messageWarning = toView . viewMessageError "warning" messageError :: Text -> m () - messageError = showMessageError "error" + messageError = toView . viewMessageError "error" newTextMessage :: ContactName -> MsgMeta -> Text -> m () newTextMessage c meta text = do - showReceivedMessage c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta)) + toView =<< liftIO (viewReceivedMessage c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta))) showToast (c <> "> ") text 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)) + toView =<< liftIO (viewReceivedGroupMessage gName c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta))) showToast ("#" <> gName <> " " <> c <> "> ") text setActive $ ActiveG gName @@ -864,7 +849,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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)) + toView =<< liftIO (viewReceivedFileInvitation c (snd $ broker meta) ft (integrity (meta :: MsgMeta))) showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c @@ -872,7 +857,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do processGroupFileInvitation gName m@GroupMember {localDisplayName = c} meta fInv = 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)) + toView =<< liftIO (viewReceivedGroupFileInvitation gName c (snd $ broker meta) ft (integrity (meta :: MsgMeta))) showToast ("#" <> gName <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG gName @@ -881,13 +866,13 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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 + toView $ viewReceivedGroupInvitation group c memRole showToast ("#" <> gName <> " " <> c <> "> ") $ "invited you to join the group" 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 $ viewContactUpdated c c' xInfoProbe :: Contact -> Probe -> m () xInfoProbe c2 probe = do @@ -913,7 +898,7 @@ 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 $ viewContactsMerged to from saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do @@ -932,7 +917,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do 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 + toView $ viewJoinedGroupMemberConnecting gName m newMember xGrpMemIntro :: Connection -> GroupName -> GroupMember -> MemberInfo -> m () xGrpMemIntro conn gName m memInfo@(MemberInfo memId _ _) = @@ -989,7 +974,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do then do mapM_ deleteMemberConnection members withStore $ \st -> updateGroupMemberStatus st userId membership GSMemRemoved - showDeletedMemberUser gName m + toView $ viewDeletedMemberUser gName m else case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.del with unknown member ID" Just member -> do @@ -999,7 +984,7 @@ 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 $ viewDeletedMember gName (Just m) (Just member) sameMemberId :: MemberId -> GroupMember -> Bool sameMemberId memId GroupMember {memberId} = memId == memberId @@ -1008,7 +993,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do xGrpLeave gName m = do deleteMemberConnection m withStore $ \st -> updateGroupMemberStatus st userId m GSMemLeft - showLeftMember gName m + toView $ viewLeftMember gName m xGrpDel :: GroupName -> GroupMember -> m () xGrpDel gName m@GroupMember {memberRole} = do @@ -1018,13 +1003,13 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do updateGroupMemberStatus st userId membership GSMemGroupDeleted pure members mapM_ deleteMemberConnection ms - showGroupDeleted gName m + toView $ viewGroupDeleted gName m parseChatMessage :: ByteString -> Either ChatError ChatMessage parseChatMessage = first ChatErrorMessage . strDecode -sendFileChunk :: ChatMonad m => SndFileTransfer -> m () -sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} = +sendFileChunk :: ChatMonad m => ([StyledString] -> m ()) -> SndFileTransfer -> m () +sendFileChunk toView ft@SndFileTransfer {fileId, fileStatus, agentConnId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo @@ -1032,7 +1017,7 @@ sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} = withStore $ \st -> do updateSndFileStatus st ft FSComplete deleteSndFileChunks st ft - showSndFileComplete ft + toView $ viewSndFileComplete ft closeFileHandle fileId sndFiles withAgent (`deleteConnection` agentConnId) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c3d2d7d38d..509322a9b1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Controller where @@ -14,9 +15,8 @@ import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Map.Strict (Map) import Numeric.Natural -import Simplex.Chat.Notification import Simplex.Chat.Store (StoreError) -import Simplex.Chat.Terminal +import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) @@ -41,14 +41,18 @@ data ChatConfig = ChatConfig fileChunkSize :: Integer } +data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName + deriving (Eq) + data ChatController = ChatController { currentUser :: TVar User, + activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, - chatTerminal :: ChatTerminal, chatStore :: SQLiteStore, idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue InputEvent, + outputQ :: TBQueue [StyledString], notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), chatLock :: TMVar (), @@ -90,9 +94,9 @@ data ChatErrorType type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m, MonadFail m) 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/migrations/20220101_initial.sql b/src/Simplex/Chat/Migrations/M20220101_initial.hs similarity index 97% rename from migrations/20220101_initial.sql rename to src/Simplex/Chat/Migrations/M20220101_initial.hs index 27b8c5108a..a326ba0604 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 @@ -257,3 +267,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/Mobile.hs b/src/Simplex/Chat/Mobile.hs new file mode 100644 index 0000000000..33cb548613 --- /dev/null +++ b/src/Simplex/Chat/Mobile.hs @@ -0,0 +1,126 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Mobile where + +import Control.Concurrent (forkIO) +import Control.Concurrent.STM +import Control.Monad.Except +import Control.Monad.Reader +import Data.Aeson ((.=)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as JE +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 Simplex.Chat +import Simplex.Chat.Controller +import Simplex.Chat.Options +import Simplex.Chat.Store +import Simplex.Chat.Styled +import Simplex.Chat.Types + +foreign export ccall "chat_init_store" cChatInitStore :: CString -> IO (StablePtr ChatStore) + +foreign export ccall "chat_get_user" cChatGetUser :: StablePtr ChatStore -> IO CJSONString + +foreign export ccall "chat_create_user" cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString + +foreign export ccall "chat_start" cChatStart :: StablePtr ChatStore -> 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 CString + +-- | creates or connects to chat store +cChatInitStore :: CString -> IO (StablePtr ChatStore) +cChatInitStore fp = peekCString fp >>= chatInitStore >>= newStablePtr + +-- | returns JSON in the form `{"user": }` or `{}` in case there is no active user (to show dialog to enter displayName/fullName) +cChatGetUser :: StablePtr ChatStore -> IO CJSONString +cChatGetUser cc = deRefStablePtr cc >>= chatGetUser >>= newCString + +-- | accepts Profile JSON, returns JSON `{"user": }` or `{"error": ""}` +cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString +cChatCreateUser cPtr profileCJson = do + c <- deRefStablePtr cPtr + p <- peekCString profileCJson + newCString =<< chatCreateUser c p + +-- | this function starts chat - it cannot be started during initialization right now, as it cannot work without user (to be fixed later) +cChatStart :: StablePtr ChatStore -> IO (StablePtr ChatController) +cChatStart st = deRefStablePtr st >>= chatStart >>= 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 <- peekCString cCmd + newCString =<< chatSendCmd c cmd + +-- | receive message from chat (blocking) +cChatRecvMsg :: StablePtr ChatController -> IO CString +cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCString + +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 + } + +type CJSONString = CString + +type JSONString = String + +data ChatStore = ChatStore + { dbFilePrefix :: FilePath, + chatStore :: SQLiteStore + } + +chatInitStore :: String -> IO ChatStore +chatInitStore dbFilePrefix = do + let f = chatStoreFile dbFilePrefix + chatStore <- createStore f $ dbPoolSize defaultChatConfig + pure ChatStore {dbFilePrefix, chatStore} + +getActiveUser_ :: SQLiteStore -> IO (Maybe User) +getActiveUser_ st = find activeUser <$> getUsers st + +-- | returns JSON in the form `{"user": }` or `{}` +chatGetUser :: ChatStore -> IO JSONString +chatGetUser ChatStore {chatStore} = + maybe "{}" (jsonObject . ("user" .=)) <$> getActiveUser_ chatStore + +-- | returns JSON in the form `{"user": }` or `{"error": ""}` +chatCreateUser :: ChatStore -> JSONString -> IO JSONString +chatCreateUser ChatStore {chatStore} profileJson = + case J.eitherDecodeStrict' $ B.pack profileJson of + Left e -> err e + Right p -> + runExceptT (createUser chatStore p True) >>= \case + Right user -> pure . jsonObject $ "user" .= user + Left e -> err e + where + err e = pure . jsonObject $ "error" .= show e + +chatStart :: ChatStore -> IO ChatController +chatStart ChatStore {dbFilePrefix, chatStore} = do + Just user <- getActiveUser_ chatStore + cc <- newChatController chatStore user defaultChatConfig mobileChatOpts {dbFilePrefix} . const $ pure () + void . forkIO $ runReaderT runChatController cc + pure cc + +chatSendCmd :: ChatController -> String -> IO JSONString +chatSendCmd ChatController {inputQ} s = atomically (writeTBQueue inputQ $ InputCommand s) >> pure "{}" + +chatRecvMsg :: ChatController -> IO String +chatRecvMsg ChatController {outputQ} = unlines . map unStyle <$> atomically (readTBQueue outputQ) + +jsonObject :: J.Series -> JSONString +jsonObject = LB.unpack . JE.encodingToLazyByteString . J.pairs diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index f7504aabda..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,11 +19,20 @@ import Simplex.Messaging.Parsers (parseAll) import System.FilePath (combine) data ChatOpts = ChatOpts - { dbFile :: String, + { 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 @@ -38,13 +52,7 @@ 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" diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 8efad9c137..d025d41296 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -108,7 +108,6 @@ import Crypto.Random (ChaChaDRG, randomBytesGenerate) 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) @@ -116,11 +115,11 @@ import Data.List (find, sortBy) import Data.Maybe (listToMaybe) 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 Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Migrations.M20220101_initial import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AParty (..), AgentMsgId, ConnId, InvitationId, MsgMeta (..)) @@ -128,17 +127,19 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore 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 System.FilePath (takeFileName) import UnliftIO.STM +schemaMigrations :: [(String, Query)] +schemaMigrations = + [ ("20220101_initial", m20220101_initial) + ] + -- | 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 dbFilePath poolSize = createSQLiteStore dbFilePath poolSize migrations diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index f7a3a80acf..a15bd90be4 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, ) @@ -69,6 +70,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..5a658ca5da 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,176 +1,38 @@ -{-# LANGUAGE GADTs #-} -{-# LANGUAGE LambdaCase #-} {-# 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.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.Chat.Util (whenM) +import Simplex.Messaging.Util (raceAny_) -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 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 cfg + user <- getCreateActiveUser st + ct <- newChatTerminal t + cc <- newChatController st user cfg opts sendNotification + runSimplexChat user 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 - --- 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 +runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () +runSimplexChat user ct = runReaderT $ do + whenM (asks firstTime) . liftIO . printToTerminal ct $ chatWelcome user + raceAny_ [runTerminalInput ct, runTerminalOutput ct, runChatController] diff --git a/src/Simplex/Chat/Input.hs b/src/Simplex/Chat/Terminal/Input.hs similarity index 92% rename from src/Simplex/Chat/Input.hs rename to src/Simplex/Chat/Terminal/Input.hs index 5369c3db9a..8c5f7b8cf7 100644 --- a/src/Simplex/Chat/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -2,14 +2,14 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} -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 Simplex.Chat.Controller -import Simplex.Chat.Terminal +import Simplex.Chat.Terminal.Output import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM @@ -21,16 +21,16 @@ getKey = Right (KeyEvent key ms) -> pure (key, ms) _ -> getKey -runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => m () -runTerminalInput = do - ChatController {inputQ, chatTerminal = ct} <- ask +runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () +runTerminalInput ct = do + cc <- ask liftIO $ withChatTerm ct $ do updateInput ct - receiveFromTTY inputQ ct + receiveFromTTY cc ct -receiveFromTTY :: MonadTerminal m => TBQueue InputEvent -> ChatTerminal -> m () -receiveFromTTY inputQ ct@ChatTerminal {activeTo, termSize, termState} = +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 () 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..4100504f77 --- /dev/null +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -0,0 +1,179 @@ +{-# 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 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 :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () +runTerminalOutput ct = do + ChatController {outputQ} <- ask + forever $ + atomically (readTBQueue outputQ) >>= liftIO . printToTerminal ct + +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..078add3a13 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -60,6 +60,9 @@ data User = User profile :: Profile, activeUser :: Bool } + deriving (Generic, FromJSON) + +instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions type UserId = Int64 @@ -743,3 +746,5 @@ msgDeliveryStatusT' s = case testEquality d (msgDirection @d) of Just Refl -> Just st _ -> Nothing + +data Notification = Notification {title :: Text, text :: Text} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 96499a999e..9158bdf3d1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -7,85 +7,83 @@ {-# 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, + ( safeDecodeUtf8, msgPlain, clientVersionInfo, + viewConnReqInvitation, + viewSentConfirmation, + viewSentInvitation, + viewInvalidConnReq, + viewContactDeleted, + viewContactGroups, + viewContactsList, + viewUserContactLinkCreated, + viewUserContactLinkDeleted, + viewUserContactLink, + viewAcceptingContactRequest, + viewContactRequestRejected, + viewGroupCreated, + viewSentGroupInvitation, + viewCannotResendInvitation, + viewDeletedMember, + viewLeftMemberUser, + viewGroupDeletedUser, + viewGroupMembers, + viewSentFileInfo, + viewRcvFileAccepted, + viewRcvFileSndCancelled, + viewSndGroupFileCancelled, + viewRcvFileCancelled, + viewFileTransferStatus, + viewUserProfileUpdated, + viewUserProfile, + viewChatError, + viewSentMessage, + viewSentGroupMessage, + viewSentGroupFileInvitation, + viewSentFileInvitation, + viewGroupsList, + viewContactSubscribed, + viewContactSubError, + viewGroupInvitation, + viewGroupEmpty, + viewGroupRemoved, + viewMemberSubError, + viewGroupSubscribed, + viewSndFileSubError, + viewRcvFileSubError, + viewUserContactLinkSubscribed, + viewUserContactLinkSubError, + viewContactConnected, + viewContactDisconnected, + viewContactAnotherClient, + viewJoinedGroupMember, + viewUserJoinedGroup, + viewJoinedGroupMemberConnecting, + viewConnectedToGroupMember, + viewReceivedGroupInvitation, + viewDeletedMemberUser, + viewLeftMember, + viewSndFileStart, + viewSndFileComplete, + viewSndFileCancelled, + viewSndFileRcvCancelled, + viewRcvFileStart, + viewRcvFileComplete, + viewReceivedContactRequest, + viewMessageError, + viewReceivedMessage, + viewReceivedGroupMessage, + viewReceivedFileInvitation, + viewReceivedGroupFileInvitation, + viewContactUpdated, + viewContactsMerged, + viewGroupDeleted, ) where -import Control.Monad.IO.Unlift -import Control.Monad.Reader import Data.ByteString.Char8 (ByteString) -import Data.Composition ((.:), (.:.)) +import Data.Composition ((.:)) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intersperse, sort, sortOn) @@ -99,7 +97,6 @@ import Simplex.Chat.Controller import Simplex.Chat.Markdown 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 @@ -107,227 +104,25 @@ 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) +viewSentConfirmation :: [StyledString] +viewSentConfirmation = ["confirmation sent!"] -showInvitation :: ChatReader m => ConnReqInvitation -> m () -showInvitation = printToView . connReqInvitation_ +viewSentInvitation :: [StyledString] +viewSentInvitation = ["connection request sent!"] -showSentConfirmation :: ChatReader m => m () -showSentConfirmation = printToView ["confirmation sent!"] +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 + ] -showSentInvitation :: ChatReader m => m () -showSentInvitation = printToView ["connection request sent!"] +viewUserContactLinkSubscribed :: [StyledString] +viewUserContactLinkSubscribed = ["Your address is active! To show: " <> highlight' "/sa"] -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 = +viewConnReqInvitation :: ConnReqInvitation -> [StyledString] +viewConnReqInvitation cReq = [ "pass this invitation link to your contact (via another channel): ", "", (plain . strEncode) cReq, @@ -335,48 +130,48 @@ connReqInvitation_ cReq = "and ask them to connect: " <> highlight' "/c " ] -contactDeleted :: ContactName -> [StyledString] -contactDeleted c = [ttyContact c <> ": contact is deleted"] +viewContactDeleted :: ContactName -> [StyledString] +viewContactDeleted 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] +viewContactGroups :: ContactName -> [GroupName] -> [StyledString] +viewContactGroups 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"] +viewContactConnected :: Contact -> [StyledString] +viewContactConnected ct = [ttyFullContact ct <> ": contact is connected"] -contactDisconnected :: ContactName -> [StyledString] -contactDisconnected c = [ttyContact c <> ": disconnected from server (messages will be queued)"] +viewContactDisconnected :: ContactName -> [StyledString] +viewContactDisconnected c = [ttyContact c <> ": disconnected from server (messages will be queued)"] -contactAnotherClient :: ContactName -> [StyledString] -contactAnotherClient c = [ttyContact c <> ": contact is connected to another client"] +viewContactAnotherClient :: ContactName -> [StyledString] +viewContactAnotherClient c = [ttyContact c <> ": contact is connected to another client"] -contactSubscribed :: ContactName -> [StyledString] -contactSubscribed c = [ttyContact c <> ": connected to server"] +viewContactSubscribed :: ContactName -> [StyledString] +viewContactSubscribed c = [ttyContact c <> ": connected to server"] -contactSubError :: ContactName -> ChatError -> [StyledString] -contactSubError c e = [ttyContact c <> ": contact error " <> sShow e] +viewContactSubError :: ContactName -> ChatError -> [StyledString] +viewContactSubError c e = [ttyContact c <> ": contact error " <> sShow e] -userContactLinkCreated :: ConnReqContact -> [StyledString] -userContactLinkCreated = connReqContact_ "Your new chat address is created!" +viewUserContactLinkCreated :: ConnReqContact -> [StyledString] +viewUserContactLinkCreated = 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:" +viewUserContactLink :: ConnReqContact -> [StyledString] +viewUserContactLink = connReqContact_ "Your chat address:" connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = @@ -389,90 +184,90 @@ 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..."] +viewAcceptingContactRequest :: ContactName -> [StyledString] +viewAcceptingContactRequest c = [ttyContact c <> ": accepting contact request..."] -contactRequestRejected :: ContactName -> [StyledString] -contactRequestRejected c = [ttyContact c <> ": contact request rejected"] +viewContactRequestRejected :: ContactName -> [StyledString] +viewContactRequestRejected c = [ttyContact c <> ": contact request rejected"] -userContactLinkSubError :: ChatError -> [StyledString] -userContactLinkSubError e = +viewUserContactLinkSubError :: ChatError -> [StyledString] +viewUserContactLinkSubError e = [ "user address error: " <> sShow e, "to delete your address: " <> highlight' "/da" ] -groupSubscribed :: Group -> [StyledString] -groupSubscribed g = [ttyFullGroup g <> ": connected to server(s)"] +viewGroupSubscribed :: Group -> [StyledString] +viewGroupSubscribed g = [ttyFullGroup g <> ": connected to server(s)"] -groupEmpty :: Group -> [StyledString] -groupEmpty g = [ttyFullGroup g <> ": group is empty"] +viewGroupEmpty :: Group -> [StyledString] +viewGroupEmpty g = [ttyFullGroup g <> ": group is empty"] -groupRemoved :: Group -> [StyledString] -groupRemoved g = [ttyFullGroup g <> ": you are no longer a member or group deleted"] +viewGroupRemoved :: Group -> [StyledString] +viewGroupRemoved 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] +viewMemberSubError :: GroupName -> ContactName -> ChatError -> [StyledString] +viewMemberSubError g c e = [ttyGroup g <> " member " <> ttyContact c <> " error: " <> sShow e] -groupCreated :: Group -> [StyledString] -groupCreated g@Group {localDisplayName} = +viewGroupCreated :: Group -> [StyledString] +viewGroupCreated g@Group {localDisplayName} = [ "group " <> ttyFullGroup g <> " is created", "use " <> highlight ("/a " <> localDisplayName <> " ") <> " to add members" ] -groupDeletedUser :: GroupName -> [StyledString] -groupDeletedUser g = groupDeleted_ g Nothing +viewGroupDeletedUser :: GroupName -> [StyledString] +viewGroupDeletedUser 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"] +viewGroupDeleted :: GroupName -> GroupMember -> [StyledString] +viewGroupDeleted 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] +viewSentGroupInvitation :: GroupName -> ContactName -> [StyledString] +viewSentGroupInvitation g c = ["invitation to join the group " <> ttyGroup g <> " sent to " <> ttyContact c] -cannotResendInvitation :: GroupName -> ContactName -> [StyledString] -cannotResendInvitation g c = +viewCannotResendInvitation :: GroupName -> ContactName -> [StyledString] +viewCannotResendInvitation g c = [ ttyContact c <> " is already invited to group " <> ttyGroup g, "to re-send invitation: " <> highlight ("/rm " <> g <> " " <> c) <> ", " <> highlight ("/a " <> g <> " " <> c) ] -receivedGroupInvitation :: Group -> ContactName -> GroupMemberRole -> [StyledString] -receivedGroupInvitation g@Group {localDisplayName} c role = +viewReceivedGroupInvitation :: Group -> ContactName -> GroupMemberRole -> [StyledString] +viewReceivedGroupInvitation 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" ] -joinedGroupMember :: GroupName -> GroupMember -> [StyledString] -joinedGroupMember g m = [ttyGroup g <> ": " <> ttyMember m <> " joined the group "] +viewJoinedGroupMember :: GroupName -> GroupMember -> [StyledString] +viewJoinedGroupMember g m = [ttyGroup g <> ": " <> ttyMember m <> " joined the group "] -userJoinedGroup :: GroupName -> [StyledString] -userJoinedGroup g = [ttyGroup g <> ": you joined the group"] +viewUserJoinedGroup :: GroupName -> [StyledString] +viewUserJoinedGroup 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...)"] +viewJoinedGroupMemberConnecting :: GroupName -> GroupMember -> GroupMember -> [StyledString] +viewJoinedGroupMemberConnecting 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"] +viewConnectedToGroupMember :: GroupName -> GroupMember -> [StyledString] +viewConnectedToGroupMember 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"] +viewDeletedMember :: GroupName -> Maybe GroupMember -> Maybe GroupMember -> [StyledString] +viewDeletedMember 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 +viewDeletedMemberUser :: GroupName -> GroupMember -> [StyledString] +viewDeletedMemberUser g by = viewDeletedMember g (Just by) Nothing <> groupPreserved g -leftMemberUser :: GroupName -> [StyledString] -leftMemberUser g = leftMember_ g Nothing <> groupPreserved g +viewLeftMemberUser :: GroupName -> [StyledString] +viewLeftMemberUser g = leftMember_ g Nothing <> groupPreserved g -leftMember :: GroupName -> GroupMember -> [StyledString] -leftMember g m = leftMember_ g (Just m) +viewLeftMember :: GroupName -> GroupMember -> [StyledString] +viewLeftMember g m = leftMember_ g (Just m) leftMember_ :: GroupName -> Maybe GroupMember -> [StyledString] leftMember_ g m = [ttyGroup g <> ": " <> memberOrUser m <> " left the group"] @@ -489,8 +284,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 {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,13 +304,17 @@ 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 :: [(GroupName, Text, GroupMemberStatus)] -> [StyledString] +viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] +viewGroupsList gs = map groupSS $ sort gs where groupSS (displayName, fullName, GSMemInvited) = groupInvitation displayName fullName groupSS (displayName, fullName, _) = ttyGroup displayName <> optFullName displayName fullName +viewGroupInvitation :: Group -> [StyledString] +viewGroupInvitation Group {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = + [groupInvitation ldn fullName] + groupInvitation :: GroupName -> Text -> StyledString groupInvitation displayName fullName = highlight ("#" <> displayName) @@ -526,21 +325,21 @@ 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 _to@Contact {localDisplayName = c1} _from@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 +viewUserProfileUpdated :: User -> User -> [StyledString] +viewUserProfileUpdated User {localDisplayName = n, profile = Profile {fullName}} User {localDisplayName = n', profile = Profile {fullName = fullName'}} | n == n' && fullName == fullName' = [] @@ -549,8 +348,8 @@ userProfileUpdated 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,11 +361,17 @@ 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] +viewMessageError :: Text -> Text -> [StyledString] +viewMessageError prefix err = [plain prefix <> ": " <> plain err] -receivedMessage :: StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] -receivedMessage from utcTime msg mOk = do +viewReceivedMessage :: ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] +viewReceivedMessage = viewReceivedMessage_ . ttyFromContact + +viewReceivedGroupMessage :: GroupName -> ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] +viewReceivedGroupMessage = viewReceivedMessage_ .: ttyFromGroup + +viewReceivedMessage_ :: StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] +viewReceivedMessage_ from utcTime msg mOk = do t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime pure $ prependFirst (t <> " " <> from) msg ++ showIntegrity mOk where @@ -591,14 +396,26 @@ receivedMessage from utcTime msg mOk = do msgError :: String -> [StyledString] msgError s = [styled (Colored Red) s] -sentMessage :: StyledString -> ByteString -> IO [StyledString] -sentMessage to msg = sendWithTime_ to . msgPlain $ safeDecodeUtf8 msg +viewSentMessage :: ContactName -> ByteString -> IO [StyledString] +viewSentMessage = viewSentMessage_ . ttyToContact -sentFileInvitation :: StyledString -> FilePath -> IO [StyledString] -sentFileInvitation to f = sendWithTime_ ("/f " <> to) [ttyFilePath f] +viewSentGroupMessage :: GroupName -> ByteString -> IO [StyledString] +viewSentGroupMessage = viewSentMessage_ . ttyToGroup -sendWithTime_ :: StyledString -> [StyledString] -> IO [StyledString] -sendWithTime_ to styledMsg = do +viewSentMessage_ :: StyledString -> ByteString -> IO [StyledString] +viewSentMessage_ to msg = sentWithTime_ to . msgPlain $ safeDecodeUtf8 msg + +viewSentFileInvitation :: ContactName -> FilePath -> IO [StyledString] +viewSentFileInvitation = viewSentFileInvitation_ . ttyToContact + +viewSentGroupFileInvitation :: GroupName -> FilePath -> IO [StyledString] +viewSentGroupFileInvitation = viewSentFileInvitation_ . ttyToGroup + +viewSentFileInvitation_ :: StyledString -> FilePath -> IO [StyledString] +viewSentFileInvitation_ to f = sentWithTime_ ("/f " <> to) [ttyFilePath f] + +sentWithTime_ :: StyledString -> [StyledString] -> IO [StyledString] +sentWithTime_ to styledMsg = do time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime pure $ prependFirst (styleTime time <> " " <> to) styledMsg @@ -609,21 +426,21 @@ prependFirst s (s' : ss) = (s <> s') : ss msgPlain :: Text -> [StyledString] msgPlain = map styleMarkdownText . T.lines -sentFileInfo :: Int64 -> [StyledString] -sentFileInfo fileId = +viewSentFileInfo :: Int64 -> [StyledString] +viewSentFileInfo fileId = ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"] -sndFileStart :: SndFileTransfer -> [StyledString] -sndFileStart = sendingFile_ "started" +viewSndFileStart :: SndFileTransfer -> [StyledString] +viewSndFileStart = sendingFile_ "started" -sndFileComplete :: SndFileTransfer -> [StyledString] -sndFileComplete = sendingFile_ "completed" +viewSndFileComplete :: SndFileTransfer -> [StyledString] +viewSndFileComplete = sendingFile_ "completed" -sndFileCancelled :: SndFileTransfer -> [StyledString] -sndFileCancelled = sendingFile_ "cancelled" +viewSndFileCancelled :: SndFileTransfer -> [StyledString] +viewSndFileCancelled = 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 +449,21 @@ 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} = +viewSndFileRcvCancelled :: SndFileTransfer -> [StyledString] +viewSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} = [ttyContact c <> " cancelled receiving " <> sndFile ft] sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName -receivedFileInvitation :: RcvFileTransfer -> [StyledString] -receivedFileInvitation RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = +viewReceivedFileInvitation :: ContactName -> UTCTime -> RcvFileTransfer -> MsgIntegrity -> IO [StyledString] +viewReceivedFileInvitation c ts = viewReceivedMessage c ts . receivedFileInvitation_ + +viewReceivedGroupFileInvitation :: GroupName -> ContactName -> UTCTime -> RcvFileTransfer -> MsgIntegrity -> IO [StyledString] +viewReceivedGroupFileInvitation g c ts = viewReceivedGroupMessage g c ts . receivedFileInvitation_ + +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,25 +480,25 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -rcvFileAccepted :: RcvFileTransfer -> FilePath -> [StyledString] -rcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath = +viewRcvFileAccepted :: RcvFileTransfer -> FilePath -> [StyledString] +viewRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath = ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] -rcvFileStart :: RcvFileTransfer -> [StyledString] -rcvFileStart = receivingFile_ "started" +viewRcvFileStart :: RcvFileTransfer -> [StyledString] +viewRcvFileStart = receivingFile_ "started" -rcvFileComplete :: RcvFileTransfer -> [StyledString] -rcvFileComplete = receivingFile_ "completed" +viewRcvFileComplete :: RcvFileTransfer -> [StyledString] +viewRcvFileComplete = receivingFile_ "completed" -rcvFileCancelled :: RcvFileTransfer -> [StyledString] -rcvFileCancelled = receivingFile_ "cancelled" +viewRcvFileCancelled :: RcvFileTransfer -> [StyledString] +viewRcvFileCancelled = 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} = +viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] +viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = [ttyContact c <> " cancelled sending " <> rcvFile ft] rcvFile :: RcvFileTransfer -> StyledString @@ -684,8 +507,8 @@ rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = f fileTransfer :: Int64 -> String -> StyledString fileTransfer 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 +517,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 +533,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,16 +550,16 @@ 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 = +viewSndFileSubError :: SndFileTransfer -> ChatError -> [StyledString] +viewSndFileSubError SndFileTransfer {fileId, fileName} e = ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] -rcvFileSubError :: RcvFileTransfer -> ChatError -> [StyledString] -rcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e = +viewRcvFileSubError :: RcvFileTransfer -> ChatError -> [StyledString] +viewRcvFileSubError 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 CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"] @@ -777,9 +600,6 @@ chatError = \case 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) diff --git a/stack.yaml b/stack.yaml index e7e09510eb..9310861bfb 100644 --- a/stack.yaml +++ b/stack.yaml @@ -40,12 +40,12 @@ extra-deps: # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/haskell-terminal commit: f708b00009b54890172068f168bf98508ffcd495 - - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 + # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - # - github: simplex-chat/simplexmq - # commit: bfa4911217b71527a6fbaf73b242b5684aaf9fce + - github: simplex-chat/simplexmq + commit: 670b3b79749bfb48a04ee40b8c441e9ca68ad41a - github: simplex-chat/hs-tls - commit: cea6d52c512716ff09adcac86ebc95bb0b3bb797 + commit: f6cc753611f80af300401cfae63846e9d7c40d9e subdirs: - core diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 3e351a35e3..5a214e83a8 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 @@ -38,7 +40,7 @@ serverPort = "5001" opts :: ChatOpts opts = ChatOpts - { dbFile = undefined, + { dbFilePrefix = undefined, smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], logging = False } @@ -71,12 +73,13 @@ cfg = } 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 + 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 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} From 50d83d2374857cb5edbc123412ad06317ef6c7e9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 21 Jan 2022 18:58:43 +0000 Subject: [PATCH 06/82] prepare v1.0.2 (#218) * update dependencies * update version and dependencies * add tls@1.5.7 to stack.yaml * update readme Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> --- README.md | 2 +- cabal.project | 8 +------- package.yaml | 2 +- simplex-chat.cabal | 4 ++-- src/Simplex/Chat/Controller.hs | 2 +- stack.yaml | 13 +++++-------- 6 files changed, 11 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f09e01ee84..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. diff --git a/cabal.project b/cabal.project index 38cea86b8d..1de1eae020 100644 --- a/cabal.project +++ b/cabal.project @@ -3,13 +3,7 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: 670b3b79749bfb48a04ee40b8c441e9ca68ad41a - -source-repository-package - type: git - location: git://github.com/simplex-chat/hs-tls.git - tag: f6cc753611f80af300401cfae63846e9d7c40d9e - subdir: core + tag: b777a4fd93f888d549edf1877583fb7fc0e0196f source-repository-package type: git diff --git a/package.yaml b/package.yaml index 68b2df3f6e..b991ee8f50 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.0.1 +version: 1.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0e43bfbf4f..8930f5a594 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.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -31,8 +31,8 @@ library Simplex.Chat.Styled Simplex.Chat.Terminal Simplex.Chat.Terminal.Input - Simplex.Chat.Terminal.Output Simplex.Chat.Terminal.Notification + Simplex.Chat.Terminal.Output Simplex.Chat.Types Simplex.Chat.Util Simplex.Chat.View diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 509322a9b1..11e3444834 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -26,7 +26,7 @@ import System.IO (Handle) import UnliftIO.STM versionNumber :: String -versionNumber = "1.0.1" +versionNumber = "1.0.2" versionStr :: String versionStr = "SimpleX Chat v" <> versionNumber diff --git a/stack.yaml b/stack.yaml index 9310861bfb..8942dffea1 100644 --- a/stack.yaml +++ b/stack.yaml @@ -37,17 +37,14 @@ 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: f708b00009b54890172068f168bf98508ffcd495 + - tls-1.5.7@sha256:1cc30253a9696b65a9cafc0317fbf09f7dcea15e3a145ed6c9c0e28c632fa23a,6991 # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 670b3b79749bfb48a04ee40b8c441e9ca68ad41a - - github: simplex-chat/hs-tls - commit: f6cc753611f80af300401cfae63846e9d7c40d9e - subdirs: - - core + commit: b777a4fd93f888d549edf1877583fb7fc0e0196f + # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 + - github: simplex-chat/haskell-terminal + commit: f708b00009b54890172068f168bf98508ffcd495 # # extra-deps: [] From 4f5e13599278e431a0b944bbb1394b00a06089b3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 22 Jan 2022 17:54:06 +0000 Subject: [PATCH 07/82] test android app (#219) --- apps/android/.gitignore | 24 +++ apps/android/.idea/.gitignore | 3 + apps/android/.idea/.name | 1 + apps/android/.idea/codeStyles/Project.xml | 117 +++++++++++ .../.idea/codeStyles/codeStyleConfig.xml | 5 + apps/android/.idea/compiler.xml | 6 + apps/android/.idea/gradle.xml | 20 ++ apps/android/.idea/misc.xml | 18 ++ apps/android/.idea/vcs.xml | 6 + apps/android/app/build.gradle | 60 ++++++ apps/android/app/proguard-rules.pro | 21 ++ .../simplex/app/ExampleInstrumentedTest.kt | 24 +++ apps/android/app/src/main/AndroidManifest.xml | 27 +++ apps/android/app/src/main/cpp/CMakeLists.txt | 68 +++++++ apps/android/app/src/main/cpp/simplex-api.c | 72 +++++++ .../java/chat/simplex/app/MainActivity.kt | 123 ++++++++++++ .../drawable-v24/ic_launcher_foreground.xml | 30 +++ .../res/drawable/ic_launcher_background.xml | 170 ++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 49 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../app/src/main/res/values-night/themes.xml | 16 ++ .../app/src/main/res/values/colors.xml | 10 + .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/themes.xml | 16 ++ .../java/chat/simplex/app/ExampleUnitTest.kt | 17 ++ apps/android/build.gradle | 18 ++ apps/android/gradle.properties | 21 ++ .../gradle/wrapper/gradle-wrapper.properties | 6 + apps/android/gradlew | 185 ++++++++++++++++++ apps/android/gradlew.bat | 89 +++++++++ apps/android/settings.gradle | 10 + 42 files changed, 1245 insertions(+) create mode 100644 apps/android/.gitignore create mode 100644 apps/android/.idea/.gitignore create mode 100644 apps/android/.idea/.name create mode 100644 apps/android/.idea/codeStyles/Project.xml create mode 100644 apps/android/.idea/codeStyles/codeStyleConfig.xml create mode 100644 apps/android/.idea/compiler.xml create mode 100644 apps/android/.idea/gradle.xml create mode 100644 apps/android/.idea/misc.xml create mode 100644 apps/android/.idea/vcs.xml create mode 100644 apps/android/app/build.gradle create mode 100644 apps/android/app/proguard-rules.pro create mode 100644 apps/android/app/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt create mode 100644 apps/android/app/src/main/AndroidManifest.xml create mode 100644 apps/android/app/src/main/cpp/CMakeLists.txt create mode 100644 apps/android/app/src/main/cpp/simplex-api.c create mode 100644 apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt create mode 100644 apps/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 apps/android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 apps/android/app/src/main/res/layout/activity_main.xml create mode 100644 apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 apps/android/app/src/main/res/values-night/themes.xml create mode 100644 apps/android/app/src/main/res/values/colors.xml create mode 100644 apps/android/app/src/main/res/values/strings.xml create mode 100644 apps/android/app/src/main/res/values/themes.xml create mode 100644 apps/android/app/src/test/java/chat/simplex/app/ExampleUnitTest.kt create mode 100644 apps/android/build.gradle create mode 100644 apps/android/gradle.properties create mode 100644 apps/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 apps/android/gradlew create mode 100644 apps/android/gradlew.bat create mode 100644 apps/android/settings.gradle 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 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 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' From a5ad0b185c5da655c55417331cc83113307bbeeb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 22 Jan 2022 17:54:22 +0000 Subject: [PATCH 08/82] use Haskell library (#220) --- apps/ios/.gitignore | 2 + apps/ios/Shared/ContentView.swift | 120 +++++++++++++----- .../Shared/SimpleX (iOS)-Bridging-Header.h | 15 +++ .../Shared/SimpleX (macOS)-Bridging-Header.h | 15 +++ apps/ios/Shared/SimpleXApp.swift | 19 ++- apps/ios/Shared/dummy.m | 8 ++ apps/ios/SimpleX--macOS--Info.plist | 5 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 119 +++++++++++++++-- 8 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h create mode 100644 apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h create mode 100644 apps/ios/Shared/dummy.m create mode 100644 apps/ios/SimpleX--macOS--Info.plist diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore index 195fd5ee74..5137305d86 100644 --- a/apps/ios/.gitignore +++ b/apps/ios/.gitignore @@ -63,3 +63,5 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +Libraries/ diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index c560668723..bf89e2c171 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -5,46 +5,108 @@ // Created by Evgeny Poberezkin on 17/01/2022. // +//import SwiftUI + +//struct ContentView: View { +// @State var messages: [String] = ["Start session:"] +// @State var text: String = "" +// +// func sendMessage() { +// } +// +// var body: some View { +// VStack { +// ScrollView { +// LazyVStack { +// ForEach(messages, id: \.self) { msg in +// MessageView(message: msg, sent: false) +// } +// } +// .padding(10) +// } +// .frame(minWidth: 0, +// maxWidth: .infinity, +// minHeight: 0, +// maxHeight: .infinity, +// alignment: .topLeading) +// HStack { +// TextField("Message...", text: $text) +// .textFieldStyle(RoundedBorderTextFieldStyle()) +// .frame(minHeight: CGFloat(30)) +// Button(action: sendMessage) { +// Text("Send") +// }.disabled(text.isEmpty) +// } +// .frame(minHeight: CGFloat(30)) +// .padding() +// } +// } +//} + import SwiftUI struct ContentView: View { - @State var messages: [String] = ["Start session:"] - @State var text: String = "" + + var controller: controller - func sendMessage() { + init(controller: controller) { + self.controller = controller + } + + + @State private var logbuffer = [String]() + @State private var chatcmd: String = "" + @State private var chatlog: String = "" + @FocusState private var focused: Bool + + func addLine(line: String) { + print(line) + logbuffer.append(line) + if(logbuffer.count > 50) { _ = logbuffer.dropFirst() } + chatlog = logbuffer.joined(separator: "\n") } var body: some View { - VStack { - ScrollView { - LazyVStack { - ForEach(messages, id: \.self) { msg in - MessageView(message: msg, sent: false) - } + + DispatchQueue.global().async { + while(true) { + let msg = String.init(cString: chat_recv_msg(controller)) + + DispatchQueue.main.async { + addLine(line: msg) } - .padding(10) } - .frame(minWidth: 0, - maxWidth: .infinity, - minHeight: 0, - maxHeight: .infinity, - alignment: .topLeading) - HStack { - TextField("Message...", text: $text) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(minHeight: CGFloat(30)) - Button(action: sendMessage) { - Text("Send") - }.disabled(text.isEmpty) + } + + return VStack { + ScrollView { + VStack(alignment: .leading) { + HStack { Spacer() } + Text(chatlog) + .lineLimit(nil) + .font(.system(.body, design: .monospaced)) + } + .frame(maxWidth: .infinity) } - .frame(minHeight: CGFloat(30)) - .padding() + + TextField("Chat command", text: $chatcmd) + .focused($focused) + .onSubmit { + print(chatcmd) + var cCmd = chatcmd.cString(using: .utf8)! + print(String.init(cString: chat_send_cmd(controller, &cCmd))) + } + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding() } } + } -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView(text: "Hello!") - } -} + +//struct ContentView_Previews: PreviewProvider { +// static var previews: some View { +// ContentView(text: "Hello!") +// } +//} 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..4b5b9d876b --- /dev/null +++ b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h @@ -0,0 +1,15 @@ +// +// 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_store; +typedef void* controller; + +extern chat_store chat_init_store(char * path); +extern char *chat_get_user(chat_store store); +extern char *chat_create_user(chat_store store, char *data); +extern controller chat_start(chat_store store); +extern char *chat_send_cmd(controller ctl, char *cmd); +extern char *chat_recv_msg(controller 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..4b5b9d876b --- /dev/null +++ b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h @@ -0,0 +1,15 @@ +// +// 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_store; +typedef void* controller; + +extern chat_store chat_init_store(char * path); +extern char *chat_get_user(chat_store store); +extern char *chat_create_user(chat_store store, char *data); +extern controller chat_start(chat_store store); +extern char *chat_send_cmd(controller ctl, char *cmd); +extern char *chat_recv_msg(controller ctl); diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 3598b4284a..68fd67c2d8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -9,9 +9,26 @@ import SwiftUI @main struct SimpleXApp: App { + private let controller: controller + init() { + hs_init(0, nil) + + let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" + var cstr = dataDir.cString(using: .utf8)! + let store = chat_init_store(&cstr) + let user = String.init(cString: chat_get_user(store)) + print(user) + if user == "{}" { + var data = "{ \"displayName\": \"test\", \"fullName\": \"ios test\" }".cString(using: .utf8)! + chat_create_user(store, &data) + } + controller = chat_start(store) + var cmd = "/help".cString(using: .utf8)! + print(String.init(cString: chat_send_cmd(controller, &cmd))) + } var body: some Scene { WindowGroup { - ContentView() + ContentView(controller: controller) } } } 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--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 index 5a6c43804b..ea1f5ebb11 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,6 +7,20 @@ objects = { /* Begin PBXBuildFile section */ + 5C764E61279C70E0000C6508 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5D279C70DE000C6508 /* libgmp.a */; }; + 5C764E62279C70E0000C6508 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5D279C70DE000C6508 /* libgmp.a */; }; + 5C764E63279C70E0000C6508 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5E279C70DE000C6508 /* libgmpxx.a */; }; + 5C764E64279C70E0000C6508 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5E279C70DE000C6508 /* libgmpxx.a */; }; + 5C764E65279C70E0000C6508 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5F279C70DE000C6508 /* libffi.a */; }; + 5C764E66279C70E0000C6508 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5F279C70DE000C6508 /* libffi.a */; }; + 5C764E67279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */; }; + 5C764E68279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.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 */; }; 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 */; }; @@ -41,12 +55,20 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 5C764E5D279C70DE000C6508 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C764E5E279C70DE000C6508 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C764E5F279C70DE000C6508 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.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 = ""; }; 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; }; - 5CA059D2279559F40002BEB4 /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; 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 = ""; }; @@ -62,6 +84,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C764E67279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */, + 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, + 5C764E63279C70E0000C6508 /* libgmpxx.a in Frameworks */, + 5C764E65279C70E0000C6508 /* libffi.a in Frameworks */, + 5C764E61279C70E0000C6508 /* libgmp.a in Frameworks */, + 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -69,6 +97,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C764E68279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */, + 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, + 5C764E64279C70E0000C6508 /* libgmpxx.a in Frameworks */, + 5C764E66279C70E0000C6508 /* libffi.a in Frameworks */, + 5C764E62279C70E0000C6508 /* libgmp.a in Frameworks */, + 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,14 +123,36 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5C764E5C279C70B7000C6508 /* Libraries */ = { + isa = PBXGroup; + children = ( + 5C764E5F279C70DE000C6508 /* libffi.a */, + 5C764E5D279C70DE000C6508 /* libgmp.a */, + 5C764E5E279C70DE000C6508 /* libgmpxx.a */, + 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */, + ); + path = Libraries; + sourceTree = ""; + }; + 5C764E7A279C71D4000C6508 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5C764E7C279C71DB000C6508 /* libz.tbd */, + 5C764E7B279C71D4000C6508 /* libiconv.tbd */, + ); + name = Frameworks; + sourceTree = ""; + }; 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, 5CA059D1279559F40002BEB4 /* macOS */, 5CA059DA279559F40002BEB4 /* Tests iOS */, 5CA059E6279559F40002BEB4 /* Tests macOS */, 5CA059CB279559F40002BEB4 /* Products */, + 5C764E7A279C71D4000C6508 /* Frameworks */, ); sourceTree = ""; }; @@ -104,10 +160,13 @@ isa = PBXGroup; children = ( 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, + 5C764E7F279C7276000C6508 /* dummy.m */, 5CA059C4279559F40002BEB4 /* ContentView.swift */, 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */, 5CA05A4E279752D00002BEB4 /* MessageView.swift */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, + 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, + 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */, ); path = Shared; sourceTree = ""; @@ -126,7 +185,6 @@ 5CA059D1279559F40002BEB4 /* macOS */ = { isa = PBXGroup; children = ( - 5CA059D2279559F40002BEB4 /* macOS.entitlements */, ); path = macOS; sourceTree = ""; @@ -231,12 +289,15 @@ 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; @@ -307,6 +368,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CA05A4C27974EB60002BEB4 /* ProfileView.swift in Sources */, @@ -318,6 +380,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, 5CA05A4D27974EB60002BEB4 /* ProfileView.swift in Sources */, @@ -475,9 +538,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -485,16 +550,22 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries", + ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + 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"; }; @@ -505,9 +576,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -515,16 +588,21 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries", + ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + 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; @@ -536,25 +614,33 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX (macOS)Debug.entitlements"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; + 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", + ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + 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; @@ -564,25 +650,32 @@ 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 = 9767FTRA3G; + 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", + ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleX; + 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; From b38d5f34650a2ae8d0fb4fb5c95311f4ab7e824c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 24 Jan 2022 16:07:17 +0000 Subject: [PATCH 09/82] started chat model (#221) * started chat model * refactor processing commands and UI events * message chat event processing * groups: delayed delivery of messages and introductions to announced members (#217) * combine migrations, rename fields * show all view messages vis ChatResponse type * serialize chat response * update C api * remove unused extensions, fix typos Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 67 +++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 14 + simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 567 +++++++++--------- src/Simplex/Chat/Controller.hs | 135 ++++- src/Simplex/Chat/Messages.hs | 179 ++++++ .../Chat/Migrations/M20220101_initial.hs | 5 +- .../M20220122_pending_group_messages.hs | 21 + src/Simplex/Chat/Mobile.hs | 16 +- src/Simplex/Chat/Protocol.hs | 22 +- src/Simplex/Chat/Store.hs | 103 +++- src/Simplex/Chat/Styled.hs | 1 + src/Simplex/Chat/Terminal.hs | 8 +- src/Simplex/Chat/Terminal/Input.hs | 13 +- src/Simplex/Chat/Terminal/Output.hs | 3 +- src/Simplex/Chat/Types.hs | 147 +---- src/Simplex/Chat/View.hs | 443 +++++--------- tests/ChatTests.hs | 12 +- 18 files changed, 1000 insertions(+), 758 deletions(-) create mode 100644 apps/ios/Shared/Model/ChatModel.swift create mode 100644 src/Simplex/Chat/Messages.hs create mode 100644 src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift new file mode 100644 index 0000000000..df83f32b4a --- /dev/null +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -0,0 +1,67 @@ +// +// 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? + @Published var channels: [ChatChannel] = [] +} + +struct User: Codable { + var userId: Int64 + var userContactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeUser: Bool +} + +typealias ContactName = String + +typealias GroupName = String + +struct Profile: Codable { + var displayName: String + var fullName: String +} + +enum ChatChannel { + case contact(ContactInfo, [ChatMessage]) + case group(GroupInfo, [ChatMessage]) +} + +struct ContactInfo: Codable { + var contactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var viaGroup: Int64? +} + +struct GroupInfo: Codable { + var groupId: Int64 + var localDisplayName: GroupName + var groupProfile: GroupProfile +} + +struct GroupProfile: Codable { + var displayName: String + var fullName: String +} + +struct ChatMessage { + var from: ContactInfo? + var ts: Date + var content: MsgContent +} + +enum MsgContent { + case text(String) + case unknown +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ea1f5ebb11..7ff35241c8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ 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 */; }; 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 */; }; @@ -64,6 +66,7 @@ 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 = ""; }; 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 = ""; }; @@ -143,6 +146,14 @@ name = Frameworks; sourceTree = ""; }; + 5C764E87279CBC8E000C6508 /* Model */ = { + isa = PBXGroup; + children = ( + 5C764E88279CBCB3000C6508 /* ChatModel.swift */, + ); + path = Model; + sourceTree = ""; + }; 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( @@ -159,6 +170,7 @@ 5CA059C2279559F40002BEB4 /* Shared */ = { isa = PBXGroup; children = ( + 5C764E87279CBC8E000C6508 /* Model */, 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, 5C764E7F279C7276000C6508 /* dummy.m */, 5CA059C4279559F40002BEB4 /* ContentView.swift */, @@ -373,6 +385,7 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CA05A4C27974EB60002BEB4 /* ProfileView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -385,6 +398,7 @@ 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, 5CA05A4D27974EB60002BEB4 /* ProfileView.swift in Sources */, 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8930f5a594..a46f972ce0 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -23,7 +23,9 @@ library Simplex.Chat.Controller Simplex.Chat.Help Simplex.Chat.Markdown + Simplex.Chat.Messages Simplex.Chat.Migrations.M20220101_initial + Simplex.Chat.Migrations.M20220122_pending_group_messages Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a54690a2a5..ba0df44512 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 #-} @@ -26,6 +25,7 @@ import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) +import Data.Foldable (for_) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find) @@ -35,16 +35,16 @@ import Data.Maybe (isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (getCurrentTime) +import Data.Time.LocalTime (utcToLocalZonedTime) import Data.Word (Word32) import Simplex.Chat.Controller -import Simplex.Chat.Help +import Simplex.Chat.Messages import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Protocol import Simplex.Chat.Store -import Simplex.Chat.Styled import Simplex.Chat.Types -import Simplex.Chat.Util (ifM, unlessM) -import Simplex.Chat.View +import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), defaultAgentConfig) import Simplex.Messaging.Agent.Protocol @@ -52,58 +52,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 (CorrId (..), 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.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 @@ -138,103 +100,92 @@ newChatController chatStore user config@ChatConfig {agentConfig = cfg, tbqSize} rcvFiles <- newTVarIO M.empty pure ChatController {activeTo, firstTime, currentUser, smpAgent, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} -runChatController :: (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => m () -runChatController = do - q <- asks outputQ - let toView = atomically . writeTBQueue q - raceAny_ - [ inputSubscriber toView, - agentSubscriber toView, - notificationSubscriber - ] +runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => m () +runChatController = race_ agentSubscriber notificationSubscriber -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) => ([StyledString] -> m ()) -> m () -inputSubscriber toView = 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 -> toView [plain s, "invalid input: " <> plain e] - Right cmd -> do - case cmd of - SendMessage c msg -> toView =<< liftIO (viewSentMessage c msg) - SendGroupMessage g msg -> toView =<< liftIO (viewSentGroupMessage g msg) - SendFile c f -> toView =<< liftIO (viewSentFileInvitation c f) - SendGroupFile g f -> toView =<< liftIO (viewSentGroupFileInvitation g f) - _ -> toView [plain s] - user <- readTVarIO =<< asks currentUser - withAgentLock a . withLock l . void . runExceptT $ - processChatCommand toView' user cmd `catchError` (toView' . viewChatError) - where - toView' = ExceptT . fmap Right . toView +execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => String -> m ChatResponse +execChatCommand s = case parseAll chatCommandP . B.dropWhileEnd isSpace . encodeUtf8 $ T.pack s of + Left e -> pure . CRChatError . ChatError $ CECommandError e + Right cmd -> do + ChatController {chatLock = l, smpAgent = a, currentUser} <- ask + user <- readTVarIO currentUser + withAgentLock a . withLock l $ either CRChatCmdError id <$> runExceptT (processChatCommand user cmd) -processChatCommand :: forall m. ChatMonad m => ([StyledString] -> m ()) -> User -> ChatCommand -> m () -processChatCommand toView user@User {userId, profile} = \case - ChatHelp -> toView chatHelpInfo - FilesHelp -> toView filesHelpInfo - GroupsHelp -> toView groupsHelpInfo - MyAddressHelp -> toView myAddressHelpInfo - MarkdownHelp -> toView markdownInfo - Welcome -> toView $ chatWelcome user - AddContact -> do +toView :: ChatMonad m => ChatResponse -> m () +toView event = do + q <- asks outputQ + atomically $ writeTBQueue q (CorrId "", event) + +processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResponse +processChatCommand user@User {userId, profile} = \case + ChatHelp section -> pure $ CRChatHelp section + Welcome -> pure $ CRWelcome user + AddContact -> procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) withStore $ \st -> createDirectConnection st userId connId - toView $ viewConnReqInvitation cReq - Connect (Just (ACR SCMInvitation cReq)) -> connect cReq (XInfo profile) >> toView viewSentConfirmation - Connect (Just (ACR SCMContact cReq)) -> connect cReq (XContact profile Nothing) >> toView viewSentInvitation - Connect Nothing -> toView viewInvalidConnReq - ConnectAdmin -> connect adminContactReq (XContact profile Nothing) >> toView viewSentInvitation + pure $ CRInvitation cReq + Connect (Just (ACR SCMInvitation cReq)) -> procCmd $ do + connect cReq $ XInfo profile + pure CRSentConfirmation + Connect (Just (ACR SCMContact cReq)) -> procCmd $ do + connect cReq $ XContact profile Nothing + pure CRSentInvitation + Connect Nothing -> chatError CEInvalidConnReq + ConnectAdmin -> procCmd $ do + connect adminContactReq $ XContact profile Nothing + pure CRSentInvitation 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 - toView $ viewContactDeleted cName - gs -> toView $ viewContactGroups cName gs - ListContacts -> withStore (`getUserContacts` user) >>= toView . viewContactsList - CreateMyAddress -> do + procCmd $ do + withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> + deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () + withStore $ \st -> deleteContact st userId cName + unsetActive $ ActiveC cName + pure $ CRContactDeleted cName + gs -> chatError $ CEContactGroups cName gs + ListContacts -> CRContactsList <$> withStore (`getUserContacts` user) + CreateMyAddress -> procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) withStore $ \st -> createUserContactLink st userId connId cReq - toView $ viewUserContactLinkCreated cReq + pure $ CRUserContactLinkCreated cReq DeleteMyAddress -> do conns <- withStore $ \st -> getUserContactLinkConnections st userId - withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> - deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () - withStore $ \st -> deleteUserContactLink st userId - toView viewUserContactLinkDeleted - ShowMyAddress -> do - cReq <- withStore $ \st -> getUserContactLink st userId - toView $ viewUserContactLink cReq + procCmd $ do + withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> + deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () + withStore $ \st -> deleteUserContactLink st userId + pure CRUserContactLinkDeleted + ShowMyAddress -> CRUserContactLink <$> withStore (`getUserContactLink` userId) 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 - toView $ viewAcceptingContactRequest cName + procCmd $ do + connId <- withAgent $ \a -> acceptContact a agentInvitationId . directMessage $ XInfo profile + withStore $ \st -> createAcceptedContact st userId connId cName profileId + pure $ CRAcceptingContactRequest 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 - toView $ viewContactRequestRejected cName - SendMessage cName msg -> sendMessageCmd cName msg + pure $ CRContactRequestRejected cName + SendMessage cName msg -> do + contact <- withStore $ \st -> getContact st userId cName + let msgContent = MCText $ safeDecodeUtf8 msg + meta <- liftIO . mkChatMsgMeta =<< sendDirectMessage (contactConn contact) (XMsgNew msgContent) + setActive $ ActiveC cName + pure $ CRSentMessage cName msgContent meta NewGroup gProfile -> do gVar <- asks idsDrg - group <- withStore $ \st -> createNewGroup st gVar user gProfile - toView $ viewGroupCreated group + CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) AddMember gName cName memRole -> do (group, contact) <- withStore $ \st -> (,) <$> getGroup st user gName <*> getContact st userId cName let Group {groupId, groupProfile, membership, members} = group @@ -243,10 +194,10 @@ processChatCommand toView user@User {userId, profile} = \case when (memberStatus membership == GSMemInvited) $ chatError (CEGroupNotJoined gName) unless (memberActive membership) $ chatError CEGroupMemberNotActive let sendInvitation memberId cReq = do - sendDirectMessage (contactConn contact) $ + void . sendDirectMessage (contactConn contact) $ XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile - toView $ viewSentGroupInvitation gName cName setActive $ ActiveG gName + pure $ CRSentGroupInvitation gName cName case contactMember contact members of Nothing -> do gVar <- asks idsDrg @@ -257,16 +208,18 @@ processChatCommand toView user@User {userId, profile} = \case | memberStatus == GSMemInvited -> withStore (\st -> getMemberInvitation st user groupMemberId) >>= \case Just cReq -> sendInvitation memberId cReq - Nothing -> toView $ viewCannotResendInvitation gName cName - | otherwise -> chatError (CEGroupDuplicateMember cName) + Nothing -> chatError $ CEGroupCantResendInvitation 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 () + procCmd $ do + 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 + pure $ CRUserAcceptedGroupSent gName + MemberRole _gName _cName _mRole -> chatError $ CECommandError "unsupported" RemoveMember gName cName -> do Group {membership, members} <- withStore $ \st -> getGroup st user gName case find ((== cName) . (localDisplayName :: GroupMember -> ContactName)) members of @@ -274,16 +227,18 @@ processChatCommand toView user@User {userId, profile} = \case 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 - toView $ viewDeletedMember gName Nothing (Just m) + procCmd $ do + when (mStatus /= GSMemInvited) . void . sendGroupMessage members $ XGrpMemDel mId + deleteMemberConnection m + withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved + pure $ CRUserDeletedMember gName 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 - toView $ viewLeftMemberUser gName + procCmd $ do + void $ sendGroupMessage members XGrpLeave + mapM_ deleteMemberConnection members + withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft + pure $ CRLeftMemberUser gName DeleteGroup gName -> do g@Group {membership, members} <- withStore $ \st -> getGroup st user gName let s = memberStatus membership @@ -291,21 +246,21 @@ processChatCommand toView user@User {userId, profile} = \case 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 - toView $ viewGroupDeletedUser gName - ListMembers gName -> do - group <- withStore $ \st -> getGroup st user gName - toView $ viewGroupMembers group - ListGroups -> withStore (`getUserGroupDetails` userId) >>= toView . viewGroupsList + procCmd $ do + when (memberActive membership) . void $ sendGroupMessage members XGrpDel + mapM_ deleteMemberConnection members + withStore $ \st -> deleteGroup st user g + pure $ CRGroupDeletedUser gName + ListMembers gName -> CRGroupMembers <$> withStore (\st -> getGroup st user gName) + ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` userId) 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 + let msgContent = MCText $ safeDecodeUtf8 msg + meta <- liftIO . mkChatMsgMeta =<< sendGroupMessage members (XMsgNew msgContent) setActive $ ActiveG gName + pure $ CRSentGroupMessage gName msgContent meta SendFile cName f -> do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContact st userId cName @@ -313,9 +268,9 @@ processChatCommand toView user@User {userId, profile} = \case 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 - toView $ viewSentFileInfo fileId + meta <- liftIO . mkChatMsgMeta =<< sendDirectMessage (contactConn contact) (XFile fileInv) setActive $ ActiveC cName + pure $ CRSentFileInvitation cName fileId f meta SendGroupFile gName f -> do (fileSize, chSize) <- checkSndFile f group@Group {members, membership} <- withStore $ \st -> getGroup st user gName @@ -328,49 +283,65 @@ processChatCommand toView user@User {userId, profile} = \case -- TODO sendGroupMessage - same file invitation to all forM_ ms $ \(m, _, fileInv) -> traverse (`sendDirectMessage` XFile fileInv) $ memberConn m - toView $ viewSentFileInfo fileId setActive $ ActiveG gName + -- this is a hack as we have multiple direct messages instead of one per group + chatTs <- liftIO getCurrentTime + localChatTs <- liftIO $ utcToLocalZonedTime chatTs + let meta = ChatMsgMeta {msgId = 0, chatTs, localChatTs, createdAt = chatTs} + pure $ CRSentGroupFileInvitation gName fileId f meta ReceiveFile fileId filePath_ -> 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 - toView $ viewRcvFileAccepted ft filePath - Left (ChatErrorAgent (SMP SMP.AUTH)) -> toView $ viewRcvFileSndCancelled ft - Left (ChatErrorAgent (CONN DUPLICATE)) -> toView $ viewRcvFileSndCancelled ft - Left e -> throwError e - CancelFile fileId -> - withStore (\st -> getFileTransfer st userId fileId) >>= \case + 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 -> do + ft' <- withStore (\st -> getFileTransfer st userId fileId) + procCmd $ case ft' of FTSnd fts -> do forM_ fts $ \ft -> cancelSndFileTransfer ft - toView $ viewSndGroupFileCancelled fts + pure $ CRSndGroupFileCancelled fts FTRcv ft -> do cancelRcvFileTransfer ft - toView $ viewRcvFileCancelled ft + pure $ CRRcvFileCancelled ft FileStatus fileId -> - withStore (\st -> getFileTransferProgress st userId fileId) >>= toView . viewFileTransferStatus - 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 - toView $ viewUserProfileUpdated user user' - ShowProfile -> toView $ viewUserProfile profile + CRFileTransferStatus <$> withStore (\st -> getFileTransferProgress st userId fileId) + ShowProfile -> pure $ CRUserProfile profile + UpdateProfile p@Profile {displayName} + | p == profile -> pure CRUserProfileNoChange + | otherwise -> do + withStore $ \st -> updateUserProfile st user p + let user' = (user :: User) {localDisplayName = displayName, profile = p} + asks currentUser >>= atomically . (`writeTVar` user') + contacts <- withStore (`getUserContacts` user) + procCmd $ do + forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p + pure $ CRUserProfileUpdated profile p QuitChat -> liftIO exitSuccess - ShowVersion -> toView clientVersionInfo + ShowVersion -> pure CRVersionInfo where + procCmd :: m ChatResponse -> m ChatResponse + procCmd a = do + a + -- ! below code would make command responses asynchronous where they can be slow + -- ! in View.hs `r'` should be defined as `id` in this case + -- gVar <- asks idsDrg + -- corrId <- liftIO $ CorrId <$> randomBytes gVar 8 + -- q <- asks outputQ + -- void . forkIO $ atomically . writeTBQueue q =<< + -- (corrId,) <$> (a `catchError` (pure . CRChatError)) + -- pure $ CRCommandAccepted corrId + -- a corrId connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () connect 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} -> @@ -411,21 +382,24 @@ processChatCommand toView 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) => ([StyledString] -> m ()) -> m () -agentSubscriber toView = do +mkChatMsgMeta :: Message -> IO ChatMsgMeta +mkChatMsgMeta Message {msgId, chatTs, createdAt} = do + localChatTs <- utcToLocalZonedTime chatTs + pure ChatMsgMeta {msgId, chatTs, localChatTs, createdAt} + +agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () +agentSubscriber = do q <- asks $ subQ . smpAgent l <- asks chatLock - subscribeUserConnections toView + subscribeUserConnections forever $ do (_, connId, msg) <- atomically $ readTBQueue q user <- readTVarIO =<< asks currentUser withLock l . void . runExceptT $ - processAgentMessage toView' user connId msg `catchError` (toView' . viewChatError) - where - toView' = ExceptT . fmap Right . toView + processAgentMessage user connId msg `catchError` (toView . CRChatError) -subscribeUserConnections :: forall m. (MonadUnliftIO m, MonadReader ChatController m, MonadFail m) => ([StyledString] -> m ()) -> m () -subscribeUserConnections toView = void . runExceptT $ do +subscribeUserConnections :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => m () +subscribeUserConnections = void . runExceptT $ do user <- readTVarIO =<< asks currentUser subscribeContacts user subscribeGroups user @@ -433,40 +407,39 @@ subscribeUserConnections toView = void . runExceptT $ do subscribePendingConnections user subscribeUserContactLink user where - toView' = ExceptT . fmap Right . toView subscribeContacts user = do contacts <- withStore (`getUserContacts` user) forM_ contacts $ \ct@Contact {localDisplayName = c} -> - (subscribe (contactConnId ct) >> toView' (viewContactSubscribed c)) `catchError` (toView' . viewContactSubError c) + (subscribe (contactConnId ct) >> toView (CRContactSubscribed c)) `catchError` (toView . CRContactSubError c) subscribeGroups user = do groups <- withStore (`getUserGroups` user) forM_ groups $ \g@Group {members, membership, localDisplayName = gn} -> do let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members if memberStatus membership == GSMemInvited - then toView' $ viewGroupInvitation g + then toView $ CRGroupInvitation g else if null connectedMembers then if memberActive membership - then toView' $ viewGroupEmpty g - else toView' $ viewGroupRemoved g + then toView $ CRGroupEmpty g + else toView $ CRGroupRemoved g else do forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) -> - subscribe cId `catchError` (toView' . viewMemberSubError gn c) - toView' $ viewGroupSubscribed g + subscribe cId `catchError` (toView . CRMemberSubError gn c) + toView $ CRGroupSubscribed g subscribeFiles user = do withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile where subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId} = do - subscribe agentConnId `catchError` (toView' . viewSndFileSubError ft) + subscribe agentConnId `catchError` (toView . CRSndFileSubError ft) void . forkIO $ do threadDelay 1000000 l <- asks chatLock a <- asks smpAgent unless (fileStatus == FSNew) . unlessM (isFileActive fileId sndFiles) $ withAgentLock a . withLock l $ - sendFileChunk toView' ft + sendFileChunk ft subscribeRcvFile ft@RcvFileTransfer {fileStatus} = case fileStatus of RFSAccepted fInfo -> resume fInfo @@ -474,22 +447,22 @@ subscribeUserConnections toView = void . runExceptT $ do _ -> pure () where resume RcvFileInfo {agentConnId} = - subscribe agentConnId `catchError` (toView' . viewRcvFileSubError ft) + subscribe agentConnId `catchError` (toView . CRRcvFileSubError ft) subscribePendingConnections user = do cs <- withStore (`getPendingConnections` user) subscribeConns cs `catchError` \_ -> pure () subscribeUserContactLink User {userId} = do cs <- withStore (`getUserContactLinkConnections` userId) - (subscribeConns cs >> toView' viewUserContactLinkSubscribed) - `catchError` (toView' . viewUserContactLinkSubError) + (subscribeConns cs >> toView CRUserContactLinkSubscribed) + `catchError` (toView . CRUserContactLinkSubError) subscribe cId = withAgent (`subscribeConnection` cId) subscribeConns conns = withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> subscribeConnection a agentConnId -processAgentMessage :: forall m. ChatMonad m => ([StyledString] -> m ()) -> User -> ConnId -> ACommand 'Agent -> m () -processAgentMessage toView user@User {userId, profile} agentConnId agentMessage = do +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 @@ -543,11 +516,11 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage _ -> pure () Just ct@Contact {localDisplayName = c} -> case agentMsg of MSG meta msgBody -> do - chatMsgEvent <- saveRcvMSG conn meta msgBody + (chatMsgEvent, msg) <- saveRcvMSG conn meta msgBody withAckMessage agentConnId meta $ case chatMsgEvent of - XMsgNew (MCText text) -> newTextMessage c meta text - XFile fInv -> processFileInvitation ct meta fInv + XMsgNew mc -> newContentMessage c msg mc meta + XFile fInv -> processFileInvitation ct msg fInv meta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe @@ -579,7 +552,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage CON -> withStore (\st -> getViaGroupMember st user ct) >>= \case Nothing -> do - toView $ viewContactConnected ct + toView $ CRContactConnected ct setActive $ ActiveC c showToast (c <> "> ") "connected" Just (gName, m) -> @@ -589,14 +562,14 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage SENT msgId -> sentMsgDeliveryEvent conn msgId END -> do - toView $ viewContactAnotherClient c + toView $ CRContactAnotherClient c showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c DOWN -> do - toView $ viewContactDisconnected c + toView $ CRContactDisconnected c showToast (c <> "> ") "disconnected" UP -> do - toView $ viewContactSubscribed c + toView $ CRContactSubscribed c showToast (c <> "> ") "is active" setActive $ ActiveC c -- TODO print errors @@ -644,21 +617,21 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage 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 - toView $ viewUserJoinedGroup gName + toView $ CRUserJoinedGroup gName setActive $ ActiveG gName showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do - toView $ viewJoinedGroupMember gName m + toView $ CRJoinedGroupMember gName 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 + 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 @@ -671,11 +644,11 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage notifyMemberConnected gName m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct MSG meta msgBody -> do - chatMsgEvent <- saveRcvMSG conn meta msgBody + (chatMsgEvent, msg) <- saveRcvMSG conn meta msgBody withAckMessage agentConnId meta $ case chatMsgEvent of - XMsgNew (MCText text) -> newGroupTextMessage gName m meta text - XFile fInv -> processGroupFileInvitation gName m meta fInv + XMsgNew mc -> newGroupContentMessage gName m msg mc meta + XFile fInv -> processGroupFileInvitation gName m msg fInv meta XGrpMemNew memInfo -> xGrpMemNew gName m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gName m memInfo XGrpMemInv memId introInv -> xGrpMemInv gName m memId introInv @@ -708,15 +681,15 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage _ -> messageError "CONF from file connection must have x.file.acpt" CON -> do withStore $ \st -> updateSndFileStatus st ft FSConnected - toView $ viewSndFileStart ft - sendFileChunk toView ft + toView $ CRSndFileStart ft + sendFileChunk ft SENT msgId -> do withStore $ \st -> updateSndFileChunkSent st ft msgId - unless (fileStatus == FSCancelled) $ sendFileChunk toView ft + unless (fileStatus == FSCancelled) $ sendFileChunk ft MERR _ err -> do cancelSndFileTransfer ft case err of - SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ viewSndFileRcvCancelled ft + SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ CRSndFileRcvCancelled ft _ -> chatError $ CEFileSend fileId err MSG meta _ -> withAckMessage agentConnId meta $ pure () @@ -730,12 +703,12 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage case agentMsg of CON -> do withStore $ \st -> updateRcvFileStatus st ft FSConnected - toView $ viewRcvFileStart ft + toView $ CRRcvFileStart ft MSG meta@MsgMeta {recipient = (msgId, _), integrity} msgBody -> withAckMessage agentConnId meta $ do parseFileChunk msgBody >>= \case FileChunkCancel -> do cancelRcvFileTransfer ft - toView $ viewRcvFileSndCancelled ft + toView $ CRRcvFileSndCancelled ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -755,7 +728,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage withStore $ \st -> do updateRcvFileStatus st ft FSComplete deleteRcvFileChunks st ft - toView $ viewRcvFileComplete ft + toView $ CRRcvFileComplete ft closeFileHandle fileId rcvFiles withAgent (`deleteConnection` agentConnId) RcvChunkDuplicate -> pure () @@ -784,7 +757,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage profileContactRequest :: InvitationId -> Profile -> m () profileContactRequest invId p = do cName <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p - toView $ viewReceivedContactRequest cName p + toView $ CRReceivedContactRequest cName p showToast (cName <> "> ") "wants to connect to you" withAckMessage :: ConnId -> MsgMeta -> m () -> m () @@ -809,7 +782,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage notifyMemberConnected :: GroupName -> GroupMember -> m () notifyMemberConnected gName m@GroupMember {localDisplayName} = do - toView $ viewConnectedToGroupMember gName m + toView $ CRConnectedToGroupMember gName m setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName <> " is connected" @@ -817,47 +790,52 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage 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 = toView . viewMessageError "warning" + messageWarning = toView . CRMessageError "warning" messageError :: Text -> m () - messageError = toView . viewMessageError "error" + messageError = toView . CRMessageError "error" - newTextMessage :: ContactName -> MsgMeta -> Text -> m () - newTextMessage c meta text = do - toView =<< liftIO (viewReceivedMessage c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta))) - showToast (c <> "> ") text + newContentMessage :: ContactName -> Message -> MsgContent -> MsgMeta -> m () + newContentMessage c msg mc MsgMeta {integrity} = do + meta <- liftIO $ mkChatMsgMeta msg + toView $ CRReceivedMessage c meta mc integrity + showToast (c <> "> ") $ msgContentText mc setActive $ ActiveC c - newGroupTextMessage :: GroupName -> GroupMember -> MsgMeta -> Text -> m () - newGroupTextMessage gName GroupMember {localDisplayName = c} meta text = do - toView =<< liftIO (viewReceivedGroupMessage gName c (snd $ broker meta) (msgPlain text) (integrity (meta :: MsgMeta))) - showToast ("#" <> gName <> " " <> c <> "> ") text + newGroupContentMessage :: GroupName -> GroupMember -> Message -> MsgContent -> MsgMeta -> m () + newGroupContentMessage gName GroupMember {localDisplayName = c} msg mc MsgMeta {integrity} = do + meta <- liftIO $ mkChatMsgMeta msg + toView $ CRReceivedGroupMessage gName c meta mc integrity + showToast ("#" <> gName <> " " <> c <> "> ") $ msgContentText mc setActive $ ActiveG gName - processFileInvitation :: Contact -> MsgMeta -> FileInvitation -> m () - processFileInvitation contact@Contact {localDisplayName = c} meta fInv = do + processFileInvitation :: Contact -> Message -> FileInvitation -> MsgMeta -> m () + processFileInvitation contact@Contact {localDisplayName = c} msg fInv MsgMeta {integrity} = 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 - toView =<< liftIO (viewReceivedFileInvitation c (snd $ broker meta) ft (integrity (meta :: MsgMeta))) + meta <- liftIO $ mkChatMsgMeta msg + toView $ CRReceivedFileInvitation c meta ft integrity 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 :: GroupName -> GroupMember -> Message -> FileInvitation -> MsgMeta -> m () + processGroupFileInvitation gName m@GroupMember {localDisplayName = c} msg fInv MsgMeta {integrity} = do chSize <- asks $ fileChunkSize . config ft <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize - toView =<< liftIO (viewReceivedGroupFileInvitation gName c (snd $ broker meta) ft (integrity (meta :: MsgMeta))) + meta <- liftIO $ mkChatMsgMeta msg + toView $ CRReceivedGroupFileInvitation gName c meta ft integrity showToast ("#" <> gName <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG gName @@ -866,13 +844,13 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage when (fromRole < GRAdmin || fromRole < memRole) $ chatError (CEGroupContactRole c) when (fromMemId == memId) $ chatError CEGroupDuplicateMemberId group@Group {localDisplayName = gName} <- withStore $ \st -> createGroupInvitation st user ct inv - toView $ viewReceivedGroupInvitation group c memRole - showToast ("#" <> gName <> " " <> c <> "> ") $ "invited you to join the group" + toView $ CRReceivedGroupInvitation group c memRole + showToast ("#" <> gName <> " " <> c <> "> ") "invited you to join the group" xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (p == p') $ do c' <- withStore $ \st -> updateContactProfile st userId c p' - toView $ viewContactUpdated c c' + toView $ CRContactUpdated c c' xInfoProbe :: Contact -> Probe -> m () xInfoProbe c2 probe = do @@ -887,7 +865,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage 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 () @@ -898,7 +876,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do withStore $ \st -> mergeContactRecords st userId to from - toView $ viewContactsMerged to from + toView $ CRContactsMerged to from saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do @@ -917,7 +895,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage then messageError "x.grp.mem.new error: member already exists" else do newMember <- withStore $ \st -> createNewGroupMember st user group memInfo GCPostMember GSMemAnnounced - toView $ viewJoinedGroupMemberConnecting gName m newMember + toView $ CRJoinedGroupMemberConnecting gName m newMember xGrpMemIntro :: Connection -> GroupName -> GroupMember -> MemberInfo -> m () xGrpMemIntro conn gName m memInfo@(MemberInfo memId _ _) = @@ -931,7 +909,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage (directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation) newMember <- withStore $ \st -> createIntroReMember st user group 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" @@ -943,12 +921,8 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage case find (sameMemberId memId) $ members group 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 () @@ -974,7 +948,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage then do mapM_ deleteMemberConnection members withStore $ \st -> updateGroupMemberStatus st userId membership GSMemRemoved - toView $ viewDeletedMemberUser gName m + toView $ CRDeletedMemberUser gName m else case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.del with unknown member ID" Just member -> do @@ -984,7 +958,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage else do deleteMemberConnection member withStore $ \st -> updateGroupMemberStatus st userId member GSMemRemoved - toView $ viewDeletedMember gName (Just m) (Just member) + toView $ CRDeletedMember gName m member sameMemberId :: MemberId -> GroupMember -> Bool sameMemberId memId GroupMember {memberId} = memId == memberId @@ -993,7 +967,7 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage xGrpLeave gName m = do deleteMemberConnection m withStore $ \st -> updateGroupMemberStatus st userId m GSMemLeft - toView $ viewLeftMember gName m + toView $ CRLeftMember gName m xGrpDel :: GroupName -> GroupMember -> m () xGrpDel gName m@GroupMember {memberRole} = do @@ -1003,13 +977,13 @@ processAgentMessage toView user@User {userId, profile} agentConnId agentMessage updateGroupMemberStatus st userId membership GSMemGroupDeleted pure members mapM_ deleteMemberConnection ms - toView $ viewGroupDeleted gName m + toView $ CRGroupDeleted gName m parseChatMessage :: ByteString -> Either ChatError ChatMessage parseChatMessage = first ChatErrorMessage . strDecode -sendFileChunk :: ChatMonad m => ([StyledString] -> m ()) -> SndFileTransfer -> m () -sendFileChunk toView ft@SndFileTransfer {fileId, fileStatus, agentConnId} = +sendFileChunk :: ChatMonad m => SndFileTransfer -> m () +sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo @@ -1017,7 +991,7 @@ sendFileChunk toView ft@SndFileTransfer {fileId, fileStatus, agentConnId} = withStore $ \st -> do updateSndFileStatus st ft FSComplete deleteSndFileChunks st ft - toView $ viewSndFileComplete ft + toView $ CRSndFileComplete ft closeFileHandle fileId sndFiles withAgent (`deleteConnection` agentConnId) @@ -1124,13 +1098,18 @@ 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 Message 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 + msg@Message {msgId, msgBody} <- createSndMessage chatMsgEvent deliverMessage conn msgBody msgId + pure msg + +createSndMessage :: ChatMonad m => ChatMsgEvent -> m Message +createSndMessage chatMsgEvent = do + chatTs <- liftIO getCurrentTime + let msgBody = directMessage chatMsgEvent + newMsg = NewMessage {direction = MDSnd, cmEventTag = toCMEventTag chatMsgEvent, msgBody, chatTs} + withStore $ \st -> createNewMessage st newMsg directMessage :: ChatMsgEvent -> ByteString directMessage chatMsgEvent = strEncode ChatMessage {chatMsgEvent} @@ -1141,23 +1120,45 @@ deliverMessage Connection {connId, agentConnId} msgBody msgId = do 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 Message +sendGroupMessage members chatMsgEvent = + sendGroupMessage' members chatMsgEvent Nothing $ pure () -saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m ChatMsgEvent +sendXGrpMemInv :: ChatMonad m => GroupMember -> ChatMsgEvent -> Int64 -> m Message +sendXGrpMemInv reMember chatMsgEvent introId = + sendGroupMessage' [reMember] chatMsgEvent (Just introId) $ + withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) + +sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Maybe Int64 -> m () -> m Message +sendGroupMessage' members chatMsgEvent introId_ postDeliver = do + msg@Message {msgId, msgBody} <- createSndMessage chatMsgEvent + for_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} -> + case memberConn m of + Nothing -> withStore $ \st -> createPendingGroupMessage st groupMemberId msgId introId_ + Just conn -> deliverMessage conn msgBody msgId >> postDeliver + pure msg + +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 + for_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do + deliverMessage conn msgBody msgId + withStore (\st -> deletePendingGroupMessage st groupMemberId msgId) + when (cmEventTag == XGrpMemFwd_) $ case introId_ of + Nothing -> chatError $ CEGroupMemberIntroNotFound localDisplayName + Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) + +saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m (ChatMsgEvent, Message) 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 + chatTs = snd $ broker agentMsgMeta + cmEventTag = toCMEventTag chatMsgEvent + newMsg = NewMessage {direction = MDRcv, cmEventTag, chatTs, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} - withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery - pure chatMsgEvent + msg <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery + pure (chatMsgEvent, msg) allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn@Connection {agentConnId} confId msg = do @@ -1247,10 +1248,10 @@ 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 + ("/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) @@ -1276,7 +1277,7 @@ 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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 11e3444834..692f917349 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -2,7 +2,6 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Controller where @@ -12,16 +11,20 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) +import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Map.Strict (Map) +import Data.Text (Text) import Numeric.Natural +import Simplex.Chat.Messages +import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) -import Simplex.Chat.Styled 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.Protocol (CorrId) import System.IO (Handle) import UnliftIO.STM @@ -51,8 +54,8 @@ data ChatController = ChatController smpAgent :: AgentClient, chatStore :: SQLiteStore, idsDrg :: TVar ChaChaDRG, - inputQ :: TBQueue InputEvent, - outputQ :: TBQueue [StyledString], + inputQ :: TBQueue String, + outputQ :: TBQueue (CorrId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), chatLock :: TMVar (), @@ -61,7 +64,120 @@ data ChatController = ChatController config :: ChatConfig } -data InputEvent = InputCommand String | InputControl Char +data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown + deriving (Show) + +data ChatCommand + = 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 Int64 (Maybe FilePath) + | CancelFile Int64 + | FileStatus Int64 + | ShowProfile + | UpdateProfile Profile + | QuitChat + | ShowVersion + deriving (Show) + +data ChatResponse + = CRSentMessage ContactName MsgContent ChatMsgMeta + | CRSentGroupMessage GroupName MsgContent ChatMsgMeta + | CRSentFileInvitation ContactName FileTransferId FilePath ChatMsgMeta + | CRSentGroupFileInvitation GroupName FileTransferId FilePath ChatMsgMeta + | CRReceivedMessage ContactName ChatMsgMeta MsgContent MsgIntegrity + | CRReceivedGroupMessage GroupName ContactName ChatMsgMeta MsgContent MsgIntegrity + | CRReceivedFileInvitation ContactName ChatMsgMeta RcvFileTransfer MsgIntegrity + | CRReceivedGroupFileInvitation GroupName ContactName ChatMsgMeta RcvFileTransfer MsgIntegrity + | CRCommandAccepted CorrId + | CRChatHelp HelpSection + | CRWelcome User + | CRGroupCreated Group + | CRGroupMembers Group + | CRContactsList [Contact] + | CRUserContactLink ConnReqContact + | CRContactRequestRejected ContactName + | CRUserAcceptedGroupSent GroupName + | CRUserDeletedMember GroupName GroupMember + | CRGroupsList [GroupInfo] + | CRSentGroupInvitation GroupName ContactName + | CRFileTransferStatus (FileTransfer, [Integer]) + | CRUserProfile Profile + | CRUserProfileNoChange + | CRVersionInfo + | CRInvitation ConnReqInvitation + | CRSentConfirmation + | CRSentInvitation + | CRContactUpdated {fromContact :: Contact, toContact :: Contact} + | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} + | CRContactDeleted ContactName + | CRUserContactLinkCreated ConnReqContact + | CRUserContactLinkDeleted + | CRReceivedContactRequest ContactName Profile + | CRAcceptingContactRequest ContactName + | CRLeftMemberUser GroupName + | CRGroupDeletedUser GroupName + | CRRcvFileAccepted RcvFileTransfer FilePath + | CRRcvFileAcceptedSndCancelled RcvFileTransfer + | CRRcvFileStart RcvFileTransfer + | CRRcvFileComplete RcvFileTransfer + | CRRcvFileCancelled RcvFileTransfer + | CRRcvFileSndCancelled RcvFileTransfer + | CRSndFileStart SndFileTransfer + | CRSndFileComplete SndFileTransfer + | CRSndFileCancelled SndFileTransfer + | CRSndFileRcvCancelled SndFileTransfer + | CRSndGroupFileCancelled [SndFileTransfer] + | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} + | CRContactConnected Contact + | CRContactAnotherClient ContactName + | CRContactDisconnected ContactName + | CRContactSubscribed ContactName + | CRContactSubError ContactName ChatError + | CRGroupInvitation Group + | CRReceivedGroupInvitation Group ContactName GroupMemberRole + | CRUserJoinedGroup GroupName + | CRJoinedGroupMember GroupName GroupMember + | CRJoinedGroupMemberConnecting {group :: GroupName, hostMember :: GroupMember, member :: GroupMember} + | CRConnectedToGroupMember GroupName GroupMember + | CRDeletedMember {group :: GroupName, byMember :: GroupMember, deletedMember :: GroupMember} + | CRDeletedMemberUser GroupName GroupMember + | CRLeftMember GroupName GroupMember + | CRGroupEmpty Group + | CRGroupRemoved Group + | CRGroupDeleted GroupName GroupMember + | CRMemberSubError GroupName ContactName ChatError + | CRGroupSubscribed Group + | CRSndFileSubError SndFileTransfer ChatError + | CRRcvFileSubError RcvFileTransfer ChatError + | CRUserContactLinkSubscribed + | CRUserContactLinkSubError ChatError + | CRMessageError Text Text + | CRChatCmdError ChatError + | CRChatError ChatError + deriving (Show) data ChatError = ChatError ChatErrorType @@ -72,6 +188,8 @@ data ChatError data ChatErrorType = CEGroupUserRole + | CEInvalidConnReq + | CEContactGroups ContactName [GroupName] | CEGroupContactRole ContactName | CEGroupDuplicateMember ContactName | CEGroupDuplicateMemberId @@ -79,6 +197,8 @@ data ChatErrorType | CEGroupMemberNotActive | CEGroupMemberUserRemoved | CEGroupMemberNotFound ContactName + | CEGroupMemberIntroNotFound ContactName + | CEGroupCantResendInvitation GroupName ContactName | CEGroupInternal String | CEFileNotFound String | CEFileAlreadyReceiving String @@ -89,9 +209,10 @@ data ChatErrorType | CEFileRcvChunk String | CEFileInternal String | CEAgentVersion + | CECommandError String deriving (Show, Exception) -type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m, MonadFail m) +type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () setActive to = asks activeTo >>= atomically . (`writeTVar` to) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs new file mode 100644 index 0000000000..99ce67a12d --- /dev/null +++ b/src/Simplex/Chat/Messages.hs @@ -0,0 +1,179 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} + +module Simplex.Chat.Messages where + +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.ByteString.Base64 as B64 +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.Time.LocalTime (ZonedTime) +import Data.Type.Equality +import Data.Typeable (Typeable) +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import GHC.Generics +import Simplex.Chat.Protocol +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), serializeMsgIntegrity) +import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) +import Simplex.Messaging.Protocol (MsgBody) + +data NewMessage = NewMessage + { direction :: MsgDirection, + cmEventTag :: CMEventTag, + chatTs :: UTCTime, + msgBody :: MsgBody + } + deriving (Show) + +data Message = Message + { msgId :: MessageId, + direction :: MsgDirection, + cmEventTag :: CMEventTag, + chatTs :: UTCTime, + msgBody :: MsgBody, + createdAt :: UTCTime + } + deriving (Show) + +data PendingGroupMessage = PendingGroupMessage + { msgId :: MessageId, + cmEventTag :: CMEventTag, + msgBody :: MsgBody, + introId_ :: Maybe Int64 + } + +data ChatMsgMeta = ChatMsgMeta + { msgId :: MessageId, + chatTs :: UTCTime, + localChatTs :: ZonedTime, + createdAt :: UTCTime + } + deriving (Show) + +data MsgDirection = MDRcv | MDSnd + deriving (Show) + +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 diff --git a/src/Simplex/Chat/Migrations/M20220101_initial.hs b/src/Simplex/Chat/Migrations/M20220101_initial.hs index a326ba0604..b1ff292211 100644 --- a/src/Simplex/Chat/Migrations/M20220101_initial.hs +++ b/src/Simplex/Chat/Migrations/M20220101_initial.hs @@ -242,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, @@ -259,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 diff --git a/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs b/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs new file mode 100644 index 0000000000..81e81c7d7a --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220122_pending_group_messages where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220122_pending_group_messages :: Query +m20220122_pending_group_messages = + [sql| +-- 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')) +); + +ALTER TABLE messages ADD chat_ts TEXT; +|] diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 33cb548613..8b3fc64c98 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -21,8 +21,8 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Options import Simplex.Chat.Store -import Simplex.Chat.Styled import Simplex.Chat.Types +import Simplex.Chat.View foreign export ccall "chat_init_store" cChatInitStore :: CString -> IO (StablePtr ChatStore) @@ -76,8 +76,6 @@ mobileChatOpts = type CJSONString = CString -type JSONString = String - data ChatStore = ChatStore { dbFilePrefix :: FilePath, chatStore :: SQLiteStore @@ -117,10 +115,18 @@ chatStart ChatStore {dbFilePrefix, chatStore} = do pure cc chatSendCmd :: ChatController -> String -> IO JSONString -chatSendCmd ChatController {inputQ} s = atomically (writeTBQueue inputQ $ InputCommand s) >> pure "{}" +chatSendCmd cc s = crToJSON <$> runReaderT (execChatCommand s) cc chatRecvMsg :: ChatController -> IO String -chatRecvMsg ChatController {outputQ} = unlines . map unStyle <$> atomically (readTBQueue outputQ) +chatRecvMsg ChatController {outputQ} = serializeChatResponse . snd <$> atomically (readTBQueue outputQ) jsonObject :: J.Series -> JSONString jsonObject = LB.unpack . JE.encodingToLazyByteString . J.pairs + +crToJSON :: ChatResponse -> JSONString +crToJSON = \case + CRUserProfile p -> o "profile" $ J.object ["profile" .= p] + r -> o "terminal" $ J.object ["response" .= serializeChatResponse r] + where + o :: String -> J.Value -> JSONString + o tp params = jsonObject ("type" .= tp <> "params" .= params) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 0f5cb37667..2873da060f 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -22,9 +22,12 @@ 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 Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) @@ -111,6 +114,11 @@ instance ToJSON MsgContentType where 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 +169,7 @@ data CMEventTag | XInfoProbeCheck_ | XInfoProbeOk_ | XOk_ + deriving (Eq, Show) instance StrEncoding CMEventTag where strEncode = \case @@ -234,8 +243,15 @@ toCMEventTag = \case XInfoProbeOk _ -> XInfoProbeOk_ XOk -> XOk_ -toChatEventTag :: ChatMsgEvent -> Text -toChatEventTag = decodeLatin1 . strEncode . toCMEventTag +cmEventTagT :: Text -> Maybe CMEventTag +cmEventTagT = either (const Nothing) Just . 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 @@ -271,7 +287,7 @@ appToChatMessage AppMessage {event, params} = do chatToAppMessage :: ChatMessage -> AppMessage chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} where - event = toChatEventTag chatMsgEvent + event = serializeCMEventTag . toCMEventTag $ chatMsgEvent o :: [(Text, J.Value)] -> J.Object o = H.fromList params = case chatMsgEvent of diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index d025d41296..d53b0af769 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -95,6 +95,9 @@ module Simplex.Chat.Store createNewMessageAndRcvMsgDelivery, createSndMsgDeliveryEvent, createRcvMsgDeliveryEvent, + createPendingGroupMessage, + getPendingGroupMessages, + deletePendingGroupMessage, ) where @@ -119,7 +122,9 @@ import Data.Time.Clock (UTCTime, getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Messages import Simplex.Chat.Migrations.M20220101_initial +import Simplex.Chat.Migrations.M20220122_pending_group_messages import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AParty (..), AgentMsgId, ConnId, InvitationId, MsgMeta (..)) @@ -132,7 +137,8 @@ import UnliftIO.STM schemaMigrations :: [(String, Query)] schemaMigrations = - [ ("20220101_initial", m20220101_initial) + [ ("20220101_initial", m20220101_initial), + ("20220122_pending_group_messages", m20220122_pending_group_messages) ] -- | The list of migrations in ascending order by date @@ -303,18 +309,18 @@ 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'} + 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} @@ -994,19 +1000,23 @@ getUserGroups st user@User {userId} = 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 -getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> UserId -> m [(GroupName, Text, GroupMemberStatus)] +getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> UserId -> m [GroupInfo] getUserGroupDetails st userId = liftIO . withTransaction st $ \db -> - 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' - |] - (Only userId) + map groupInfo + <$> DB.query + db + [sql| + SELECT g.group_id, g.local_display_name, p.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' + |] + (Only userId) + where + groupInfo (groupId, localDisplayName, displayName, fullName, userMemberStatus) = + GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, userMemberStatus} getGroupInvitation :: StoreMonad m => SQLiteStore -> User -> GroupName -> m ReceivedGroupInvitation getGroupInvitation st user localDisplayName = @@ -1139,8 +1149,8 @@ createIntroductions st Group {members} toMember = do introId <- insertedRowId db pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} -updateIntroStatus :: MonadUnliftIO m => SQLiteStore -> GroupMemberIntro -> GroupMemberIntroStatus -> m () -updateIntroStatus st GroupMemberIntro {introId} introStatus' = +updateIntroStatus :: MonadUnliftIO m => SQLiteStore -> Int64 -> GroupMemberIntroStatus -> m () +updateIntroStatus st introId introStatus = liftIO . withTransaction st $ \db -> DB.executeNamed db @@ -1149,7 +1159,7 @@ updateIntroStatus st GroupMemberIntro {introId} introStatus' = SET intro_status = :intro_status WHERE group_member_intro_id = :intro_id |] - [":intro_status" := introStatus', ":intro_id" := introId] + [":intro_status" := introStatus, ":intro_id" := introId] saveIntroInvitation :: StoreMonad m => SQLiteStore -> GroupMember -> GroupMember -> IntroInvitation -> m GroupMemberIntro saveIntroInvitation st reMember toMember introInv = do @@ -1625,7 +1635,7 @@ getSndFileTransfers_ db userId fileId = Just recipientDisplayName -> Right SndFileTransfer {..} Nothing -> Left $ SESndFileInvalid fileId -createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> m MessageId +createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> m Message createNewMessage st newMsg = liftIO . withTransaction st $ \db -> createNewMessage_ db newMsg @@ -1636,12 +1646,13 @@ createSndMsgDelivery st sndMsgDelivery messageId = msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent -createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m () +createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m Message createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery = liftIO . withTransaction st $ \db -> do - messageId <- createNewMessage_ db newMsg - msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery messageId + msg@Message {msgId} <- createNewMessage_ db newMsg + msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery msgId createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent + pure msg createSndMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> m () createSndMsgDeliveryEvent st connId agentMsgId sndMsgDeliveryStatus = @@ -1655,17 +1666,18 @@ createRcvMsgDeliveryEvent st connId agentMsgId rcvMsgDeliveryStatus = msgDeliveryId <- ExceptT $ getMsgDeliveryId_ db connId agentMsgId liftIO $ createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus -createNewMessage_ :: DB.Connection -> NewMessage -> IO MessageId -createNewMessage_ db NewMessage {direction, chatMsgEventType, msgBody} = do +createNewMessage_ :: DB.Connection -> NewMessage -> IO Message +createNewMessage_ db NewMessage {direction, cmEventTag, chatTs, msgBody} = do createdAt <- getCurrentTime DB.execute db [sql| INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at) VALUES (?,?,?,?); + (msg_sent, chat_msg_event, chat_ts, msg_body, created_at) VALUES (?,?,?,?,?); |] - (direction, chatMsgEventType, msgBody, createdAt) - insertedRowId db + (direction, cmEventTag, chatTs, msgBody, createdAt) + msgId <- insertedRowId db + pure Message {msgId, direction, cmEventTag, chatTs, msgBody, createdAt} createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> IO Int64 createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId = do @@ -1720,6 +1732,41 @@ getMsgDeliveryId_ db connId agentMsgId = toMsgDeliveryId [Only msgDeliveryId] = Right msgDeliveryId toMsgDeliveryId _ = Left $ SENoMsgDelivery connId agentMsgId +createPendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> Maybe Int64 -> m () +createPendingGroupMessage st groupMemberId messageId introId_ = + liftIO . withTransaction st $ \db -> do + createdAt <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO pending_group_messages + (group_member_id, message_id, group_member_intro_id, created_at) VALUES (?,?,?,?) + |] + (groupMemberId, messageId, introId_, createdAt) + +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 + 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) + -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a) diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index a15bd90be4..aaed7a4f7f 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -21,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 (<>) = (:<>:) diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 5a658ca5da..648db4a561 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,5 +1,3 @@ -{-# LANGUAGE NamedFieldPuns #-} - module Simplex.Chat.Terminal where import Control.Logger.Simple @@ -24,15 +22,15 @@ simplexChat cfg opts t | otherwise = initRun where initRun = do - sendNotification <- initializeNotifications + sendNotification' <- initializeNotifications let f = chatStoreFile $ dbFilePrefix opts st <- createStore f $ dbPoolSize cfg user <- getCreateActiveUser st ct <- newChatTerminal t - cc <- newChatController st user cfg opts sendNotification + cc <- newChatController st user cfg opts sendNotification' runSimplexChat user ct cc runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () runSimplexChat user ct = runReaderT $ do whenM (asks firstTime) . liftIO . printToTerminal ct $ chatWelcome user - raceAny_ [runTerminalInput ct, runTerminalOutput ct, runChatController] + raceAny_ [runTerminalInput ct, runTerminalOutput ct, runInputLoop ct, runChatController] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 8c5f7b8cf7..3670acb438 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -1,6 +1,7 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Terminal.Input where @@ -8,8 +9,10 @@ import Control.Monad.IO.Unlift import Control.Monad.Reader import Data.List (dropWhileEnd) import qualified Data.Text as T +import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Terminal.Output +import Simplex.Chat.View import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM @@ -21,6 +24,14 @@ getKey = Right (KeyEvent key ms) -> pure (key, ms) _ -> getKey +runInputLoop :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () +runInputLoop ct = do + q <- asks inputQ + forever $ do + s <- atomically $ readTBQueue q + r <- execChatCommand s + liftIO . printToTerminal ct $ responseToView s r + runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () runTerminalInput ct = do cc <- ask @@ -45,7 +56,7 @@ receiveFromTTY ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, term 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/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 4100504f77..eb911a1785 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -12,6 +12,7 @@ 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) @@ -75,7 +76,7 @@ runTerminalOutput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerm runTerminalOutput ct = do ChatController {outputQ} <- ask forever $ - atomically (readTBQueue outputQ) >>= liftIO . printToTerminal ct + atomically (readTBQueue outputQ) >>= liftIO . printToTerminal ct . responseToView "" . snd printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 078add3a13..79bd105c52 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -8,7 +8,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Types where @@ -16,15 +15,11 @@ import Data.Aeson (FromJSON, ToJSON, (.:), (.=)) import qualified Data.Aeson as J 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 Database.SQLite.Simple (ResultError (..), SQLData (..)) import Database.SQLite.Simple.FromField (FieldParser, FromField (..), returnError) @@ -32,10 +27,9 @@ 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 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.Util ((<$?>)) class IsContact a where @@ -60,7 +54,7 @@ data User = User profile :: Profile, activeUser :: Bool } - deriving (Generic, FromJSON) + deriving (Show, Generic, FromJSON) instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions @@ -110,6 +104,14 @@ data Group = Group } deriving (Eq, Show) +data GroupInfo = GroupInfo + { groupId :: Int64, + localDisplayName :: GroupName, + groupProfile :: GroupProfile, + userMemberStatus :: GroupMemberStatus + } + deriving (Show) + data Profile = Profile { displayName :: ContactName, fullName :: Text @@ -409,7 +411,7 @@ serializeMemberStatus = \case GSMemCreator -> "creator" data SndFileTransfer = SndFileTransfer - { fileId :: Int64, + { fileId :: FileTransferId, fileName :: String, filePath :: String, fileSize :: Integer, @@ -421,6 +423,8 @@ data SndFileTransfer = SndFileTransfer } deriving (Eq, Show) +type FileTransferId = Int64 + data FileInvitation = FileInvitation { fileName :: String, fileSize :: Integer, @@ -446,7 +450,7 @@ instance ToJSON FileInvitation where <> "fileConnReq" .= fileConnReq data RcvFileTransfer = RcvFileTransfer - { fileId :: Int64, + { fileId :: FileTransferId, fileInvitation :: FileInvitation, fileStatus :: RcvFileStatus, senderDisplayName :: ContactName, @@ -470,6 +474,7 @@ data RcvFileInfo = RcvFileInfo deriving (Eq, Show) data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer + deriving (Show) data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) @@ -592,6 +597,7 @@ data GroupMemberIntro = GroupMemberIntro introStatus :: GroupMemberIntroStatus, introInvitation :: Maybe IntroInvitation } + deriving (Show) data GroupMemberIntroStatus = GMIntroPending @@ -601,6 +607,7 @@ data GroupMemberIntroStatus | GMIntroReConnected | GMIntroToConnected | GMIntroConnected + deriving (Show) instance FromField GroupMemberIntroStatus where fromField = fromTextField_ introStatusT @@ -627,124 +634,8 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data NewMessage = NewMessage - { direction :: MsgDirection, - chatMsgEventType :: Text, - msgBody :: MsgBody - } - type MessageId = Int64 -data MsgDirection = MDRcv | MDSnd - -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 - data Notification = Notification {title :: Text, text :: Text} + +type JSONString = String diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9158bdf3d1..3b78e9bcb7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,114 +1,125 @@ -{-# LANGUAGE ConstraintKinds #-} -{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -module Simplex.Chat.View - ( safeDecodeUtf8, - msgPlain, - clientVersionInfo, - viewConnReqInvitation, - viewSentConfirmation, - viewSentInvitation, - viewInvalidConnReq, - viewContactDeleted, - viewContactGroups, - viewContactsList, - viewUserContactLinkCreated, - viewUserContactLinkDeleted, - viewUserContactLink, - viewAcceptingContactRequest, - viewContactRequestRejected, - viewGroupCreated, - viewSentGroupInvitation, - viewCannotResendInvitation, - viewDeletedMember, - viewLeftMemberUser, - viewGroupDeletedUser, - viewGroupMembers, - viewSentFileInfo, - viewRcvFileAccepted, - viewRcvFileSndCancelled, - viewSndGroupFileCancelled, - viewRcvFileCancelled, - viewFileTransferStatus, - viewUserProfileUpdated, - viewUserProfile, - viewChatError, - viewSentMessage, - viewSentGroupMessage, - viewSentGroupFileInvitation, - viewSentFileInvitation, - viewGroupsList, - viewContactSubscribed, - viewContactSubError, - viewGroupInvitation, - viewGroupEmpty, - viewGroupRemoved, - viewMemberSubError, - viewGroupSubscribed, - viewSndFileSubError, - viewRcvFileSubError, - viewUserContactLinkSubscribed, - viewUserContactLinkSubError, - viewContactConnected, - viewContactDisconnected, - viewContactAnotherClient, - viewJoinedGroupMember, - viewUserJoinedGroup, - viewJoinedGroupMemberConnecting, - viewConnectedToGroupMember, - viewReceivedGroupInvitation, - viewDeletedMemberUser, - viewLeftMember, - viewSndFileStart, - viewSndFileComplete, - viewSndFileCancelled, - viewSndFileRcvCancelled, - viewRcvFileStart, - viewRcvFileComplete, - viewReceivedContactRequest, - viewMessageError, - viewReceivedMessage, - viewReceivedGroupMessage, - viewReceivedFileInvitation, - viewReceivedGroupFileInvitation, - viewContactUpdated, - viewContactsMerged, - viewGroupDeleted, - ) -where +module Simplex.Chat.View where -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 +import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError (..)) import Simplex.Chat.Styled 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 -viewSentConfirmation :: [StyledString] -viewSentConfirmation = ["confirmation sent!"] +serializeChatResponse :: ChatResponse -> String +serializeChatResponse = unlines . map unStyle . responseToView "" -viewSentInvitation :: [StyledString] -viewSentInvitation = ["connection request sent!"] +responseToView :: String -> ChatResponse -> [StyledString] +responseToView cmd = \case + CRSentMessage c mc meta -> viewSentMessage (ttyToContact c) mc meta + CRSentGroupMessage g mc meta -> viewSentMessage (ttyToGroup g) mc meta + CRSentFileInvitation c fId fPath meta -> viewSentFileInvitation (ttyToContact c) fId fPath meta + CRSentGroupFileInvitation g fId fPath meta -> viewSentFileInvitation (ttyToGroup g) fId fPath meta + CRReceivedMessage c meta mc mOk -> viewReceivedMessage (ttyFromContact c) meta mc mOk + CRReceivedGroupMessage g c meta mc mOk -> viewReceivedMessage (ttyFromGroup g c) meta mc mOk + CRReceivedFileInvitation c meta ft mOk -> viewReceivedFileInvitation (ttyFromContact c) meta ft mOk + CRReceivedGroupFileInvitation g c meta ft mOk -> viewReceivedFileInvitation (ttyFromGroup g c) meta ft mOk + CRCommandAccepted _ -> r [] + 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 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 c -> r' [ttyContact c <> ": contact is deleted"] + CRAcceptingContactRequest c -> r' [ttyContact c <> ": accepting contact request..."] + CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted + CRUserAcceptedGroupSent _gn -> 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 c p -> viewReceivedContactRequest c p + 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] + 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 Group {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 gn m -> [ttyGroup gn <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> gn) <> " to delete the local copy of the group"] + CRMemberSubError gn c e -> [ttyGroup gn <> " 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 `id` in case of asynchronous command responses + r' = r viewInvalidConnReq :: [StyledString] viewInvalidConnReq = @@ -118,9 +129,6 @@ viewInvalidConnReq = plain updateStr ] -viewUserContactLinkSubscribed :: [StyledString] -viewUserContactLinkSubscribed = ["Your address is active! To show: " <> highlight' "/sa"] - viewConnReqInvitation :: ConnReqInvitation -> [StyledString] viewConnReqInvitation cReq = [ "pass this invitation link to your contact (via another channel): ", @@ -130,49 +138,17 @@ viewConnReqInvitation cReq = "and ask them to connect: " <> highlight' "/c " ] -viewContactDeleted :: ContactName -> [StyledString] -viewContactDeleted c = [ttyContact c <> ": contact is deleted"] - -viewContactGroups :: ContactName -> [GroupName] -> [StyledString] -viewContactGroups 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 - viewContactsList :: [Contact] -> [StyledString] viewContactsList = let ldn = T.toLower . (localDisplayName :: Contact -> ContactName) in map ttyFullContact . sortOn ldn -viewContactConnected :: Contact -> [StyledString] -viewContactConnected ct = [ttyFullContact ct <> ": contact is connected"] - -viewContactDisconnected :: ContactName -> [StyledString] -viewContactDisconnected c = [ttyContact c <> ": disconnected from server (messages will be queued)"] - -viewContactAnotherClient :: ContactName -> [StyledString] -viewContactAnotherClient c = [ttyContact c <> ": contact is connected to another client"] - -viewContactSubscribed :: ContactName -> [StyledString] -viewContactSubscribed c = [ttyContact c <> ": connected to server"] - -viewContactSubError :: ContactName -> ChatError -> [StyledString] -viewContactSubError c e = [ttyContact c <> ": contact error " <> sShow e] - -viewUserContactLinkCreated :: ConnReqContact -> [StyledString] -viewUserContactLinkCreated = connReqContact_ "Your new chat address is created!" - viewUserContactLinkDeleted :: [StyledString] viewUserContactLinkDeleted = [ "Your chat address is deleted - accepted contacts will remain connected.", "To create a new chat address use " <> highlight' "/ad" ] -viewUserContactLink :: ConnReqContact -> [StyledString] -viewUserContactLink = connReqContact_ "Your chat address:" - connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = [ intro, @@ -191,48 +167,12 @@ viewReceivedContactRequest c Profile {fullName} = "to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)" ] -viewAcceptingContactRequest :: ContactName -> [StyledString] -viewAcceptingContactRequest c = [ttyContact c <> ": accepting contact request..."] - -viewContactRequestRejected :: ContactName -> [StyledString] -viewContactRequestRejected c = [ttyContact c <> ": contact request rejected"] - -viewUserContactLinkSubError :: ChatError -> [StyledString] -viewUserContactLinkSubError e = - [ "user address error: " <> sShow e, - "to delete your address: " <> highlight' "/da" - ] - -viewGroupSubscribed :: Group -> [StyledString] -viewGroupSubscribed g = [ttyFullGroup g <> ": connected to server(s)"] - -viewGroupEmpty :: Group -> [StyledString] -viewGroupEmpty g = [ttyFullGroup g <> ": group is empty"] - -viewGroupRemoved :: Group -> [StyledString] -viewGroupRemoved g = [ttyFullGroup g <> ": you are no longer a member or group deleted"] - -viewMemberSubError :: GroupName -> ContactName -> ChatError -> [StyledString] -viewMemberSubError g c e = [ttyGroup g <> " member " <> ttyContact c <> " error: " <> sShow e] - viewGroupCreated :: Group -> [StyledString] viewGroupCreated g@Group {localDisplayName} = [ "group " <> ttyFullGroup g <> " is created", "use " <> highlight ("/a " <> localDisplayName <> " ") <> " to add members" ] -viewGroupDeletedUser :: GroupName -> [StyledString] -viewGroupDeletedUser g = groupDeleted_ g Nothing - -viewGroupDeleted :: GroupName -> GroupMember -> [StyledString] -viewGroupDeleted 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"] - -viewSentGroupInvitation :: GroupName -> ContactName -> [StyledString] -viewSentGroupInvitation g c = ["invitation to join the group " <> ttyGroup g <> " sent to " <> ttyContact c] - viewCannotResendInvitation :: GroupName -> ContactName -> [StyledString] viewCannotResendInvitation g c = [ ttyContact c <> " is already invited to group " <> ttyGroup g, @@ -245,39 +185,9 @@ viewReceivedGroupInvitation g@Group {localDisplayName} c role = "use " <> highlight ("/j " <> localDisplayName) <> " to accept" ] -viewJoinedGroupMember :: GroupName -> GroupMember -> [StyledString] -viewJoinedGroupMember g m = [ttyGroup g <> ": " <> ttyMember m <> " joined the group "] - -viewUserJoinedGroup :: GroupName -> [StyledString] -viewUserJoinedGroup g = [ttyGroup g <> ": you joined the group"] - -viewJoinedGroupMemberConnecting :: GroupName -> GroupMember -> GroupMember -> [StyledString] -viewJoinedGroupMemberConnecting g host m = [ttyGroup g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - -viewConnectedToGroupMember :: GroupName -> GroupMember -> [StyledString] -viewConnectedToGroupMember g m = [ttyGroup g <> ": " <> connectedMember m <> " is connected"] - -viewDeletedMember :: GroupName -> Maybe GroupMember -> Maybe GroupMember -> [StyledString] -viewDeletedMember g by m = [ttyGroup g <> ": " <> memberOrUser by <> " removed " <> memberOrUser m <> " from the group"] - -viewDeletedMemberUser :: GroupName -> GroupMember -> [StyledString] -viewDeletedMemberUser g by = viewDeletedMember g (Just by) Nothing <> groupPreserved g - -viewLeftMemberUser :: GroupName -> [StyledString] -viewLeftMemberUser g = leftMember_ g Nothing <> groupPreserved g - -viewLeftMember :: GroupName -> GroupMember -> [StyledString] -viewLeftMember 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 - connectedMember :: GroupMember -> StyledString connectedMember m = case memberCategory m of GCPreMember -> "member " <> ttyFullMember m @@ -304,16 +214,15 @@ viewGroupMembers Group {membership, members} = map groupMember . filter (not . r GSMemCreator -> "created group" _ -> "" -viewGroupsList :: [(GroupName, Text, GroupMemberStatus)] -> [StyledString] +viewGroupsList :: [GroupInfo] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] -viewGroupsList gs = map groupSS $ sort gs +viewGroupsList gs = map groupSS $ sortOn ldn_ gs where - groupSS (displayName, fullName, GSMemInvited) = groupInvitation displayName fullName - groupSS (displayName, fullName, _) = ttyGroup displayName <> optFullName displayName fullName - -viewGroupInvitation :: Group -> [StyledString] -viewGroupInvitation Group {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = - [groupInvitation ldn fullName] + ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) + groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, userMemberStatus} = + case userMemberStatus of + GSMemInvited -> groupInvitation ldn fullName + _ -> ttyGroup ldn <> optFullName ldn fullName groupInvitation :: GroupName -> Text -> StyledString groupInvitation displayName fullName = @@ -326,7 +235,7 @@ groupInvitation displayName fullName = <> " to delete invitation)" viewContactsMerged :: Contact -> Contact -> [StyledString] -viewContactsMerged _to@Contact {localDisplayName = c1} _from@Contact {localDisplayName = c2} = +viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} = [ "contact " <> ttyContact c2 <> " is merged into " <> ttyContact c1, "use " <> ttyToContact c1 <> highlight' "" <> " to send messages" ] @@ -338,15 +247,13 @@ viewUserProfile Profile {displayName, fullName} = "(the updated profile will be sent to all your contacts)" ] -viewUserProfileUpdated :: User -> User -> [StyledString] -viewUserProfileUpdated - 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)" viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated @@ -361,25 +268,19 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewMessageError :: Text -> Text -> [StyledString] -viewMessageError prefix err = [plain prefix <> ": " <> plain err] +viewReceivedMessage :: StyledString -> ChatMsgMeta -> MsgContent -> MsgIntegrity -> [StyledString] +viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) -viewReceivedMessage :: ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] -viewReceivedMessage = viewReceivedMessage_ . ttyFromContact - -viewReceivedGroupMessage :: GroupName -> ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] -viewReceivedGroupMessage = viewReceivedMessage_ .: ttyFromGroup - -viewReceivedMessage_ :: StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString] -viewReceivedMessage_ from utcTime msg mOk = do - t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime - pure $ prependFirst (t <> " " <> from) msg ++ showIntegrity mOk +receivedWithTime_ :: StyledString -> ChatMsgMeta -> [StyledString] -> MsgIntegrity -> [StyledString] +receivedWithTime_ from ChatMsgMeta {localChatTs, createdAt} styledMsg mOk = 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 localChatTs + tz = zonedTimeZone localChatTs 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" @@ -396,28 +297,26 @@ viewReceivedMessage_ from utcTime msg mOk = do msgError :: String -> [StyledString] msgError s = [styled (Colored Red) s] -viewSentMessage :: ContactName -> ByteString -> IO [StyledString] -viewSentMessage = viewSentMessage_ . ttyToContact +viewSentMessage :: StyledString -> MsgContent -> ChatMsgMeta -> [StyledString] +viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent -viewSentGroupMessage :: GroupName -> ByteString -> IO [StyledString] -viewSentGroupMessage = viewSentMessage_ . ttyToGroup +viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> ChatMsgMeta -> [StyledString] +viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath -viewSentMessage_ :: StyledString -> ByteString -> IO [StyledString] -viewSentMessage_ to msg = sentWithTime_ to . msgPlain $ safeDecodeUtf8 msg +sentWithTime_ :: [StyledString] -> ChatMsgMeta -> [StyledString] +sentWithTime_ styledMsg ChatMsgMeta {localChatTs} = + prependFirst (ttyMsgTime localChatTs <> " ") styledMsg -viewSentFileInvitation :: ContactName -> FilePath -> IO [StyledString] -viewSentFileInvitation = viewSentFileInvitation_ . ttyToContact +ttyMsgTime :: ZonedTime -> StyledString +ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M" -viewSentGroupFileInvitation :: GroupName -> FilePath -> IO [StyledString] -viewSentGroupFileInvitation = viewSentFileInvitation_ . ttyToGroup +ttyMsgContent :: MsgContent -> [StyledString] +ttyMsgContent = \case + MCText t -> msgPlain t + MCUnknown -> ["unknown message type"] -viewSentFileInvitation_ :: StyledString -> FilePath -> IO [StyledString] -viewSentFileInvitation_ to f = sentWithTime_ ("/f " <> to) [ttyFilePath f] - -sentWithTime_ :: StyledString -> [StyledString] -> IO [StyledString] -sentWithTime_ to styledMsg = do - time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime - pure $ prependFirst (styleTime time <> " " <> to) styledMsg +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] @@ -426,18 +325,9 @@ prependFirst s (s' : ss) = (s <> s') : ss msgPlain :: Text -> [StyledString] msgPlain = map styleMarkdownText . T.lines -viewSentFileInfo :: Int64 -> [StyledString] -viewSentFileInfo fileId = - ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"] - -viewSndFileStart :: SndFileTransfer -> [StyledString] -viewSndFileStart = sendingFile_ "started" - -viewSndFileComplete :: SndFileTransfer -> [StyledString] -viewSndFileComplete = sendingFile_ "completed" - -viewSndFileCancelled :: SndFileTransfer -> [StyledString] -viewSndFileCancelled = sendingFile_ "cancelled" +viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] +viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = + [ttyContact c <> " cancelled sending " <> rcvFile ft] viewSndGroupFileCancelled :: [SndFileTransfer] -> [StyledString] viewSndGroupFileCancelled fts = @@ -449,18 +339,11 @@ sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString] sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = [status <> " sending " <> sndFile ft <> " to " <> ttyContact c] -viewSndFileRcvCancelled :: SndFileTransfer -> [StyledString] -viewSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} = - [ttyContact c <> " cancelled receiving " <> sndFile ft] - sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName -viewReceivedFileInvitation :: ContactName -> UTCTime -> RcvFileTransfer -> MsgIntegrity -> IO [StyledString] -viewReceivedFileInvitation c ts = viewReceivedMessage c ts . receivedFileInvitation_ - -viewReceivedGroupFileInvitation :: GroupName -> ContactName -> UTCTime -> RcvFileTransfer -> MsgIntegrity -> IO [StyledString] -viewReceivedGroupFileInvitation g c ts = viewReceivedGroupMessage g c ts . receivedFileInvitation_ +viewReceivedFileInvitation :: StyledString -> ChatMsgMeta -> RcvFileTransfer -> MsgIntegrity -> [StyledString] +viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] receivedFileInvitation_ RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = @@ -480,27 +363,10 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -viewRcvFileAccepted :: RcvFileTransfer -> FilePath -> [StyledString] -viewRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath = - ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] - -viewRcvFileStart :: RcvFileTransfer -> [StyledString] -viewRcvFileStart = receivingFile_ "started" - -viewRcvFileComplete :: RcvFileTransfer -> [StyledString] -viewRcvFileComplete = receivingFile_ "completed" - -viewRcvFileCancelled :: RcvFileTransfer -> [StyledString] -viewRcvFileCancelled = receivingFile_ "cancelled" - receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = [status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c] -viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] -viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = - [ttyContact c <> " cancelled sending " <> rcvFile ft] - rcvFile :: RcvFileTransfer -> StyledString rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransfer fileId fileName @@ -550,17 +416,11 @@ fileProgress :: [Integer] -> Integer -> Integer -> StyledString fileProgress chunksNum chunkSize fileSize = sShow (sum chunksNum * chunkSize * 100 `div` fileSize) <> "% of " <> humanReadableSize fileSize -viewSndFileSubError :: SndFileTransfer -> ChatError -> [StyledString] -viewSndFileSubError SndFileTransfer {fileId, fileName} e = - ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - -viewRcvFileSubError :: RcvFileTransfer -> ChatError -> [StyledString] -viewRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e = - ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - viewChatError :: ChatError -> [StyledString] viewChatError = \case ChatError err -> case err of + CEInvalidConnReq -> viewInvalidConnReq + CEContactGroups c gNames -> [ttyContact c <> ": 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"] @@ -569,6 +429,8 @@ viewChatError = \case 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] @@ -579,6 +441,7 @@ viewChatError = \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"] @@ -626,6 +489,11 @@ ttyFromContact c = styled (Colored Yellow) $ c <> "> " ttyGroup :: GroupName -> StyledString ttyGroup g = styled (Colored Blue) $ "#" <> g +ttyGroups :: [GroupName] -> StyledString +ttyGroups [] = "" +ttyGroups [g] = ttyGroup g +ttyGroups (g : gs) = ttyGroup g <> ", " <> ttyGroups gs + ttyFullGroup :: Group -> StyledString ttyFullGroup Group {localDisplayName, groupProfile = GroupProfile {fullName}} = ttyGroup localDisplayName <> optFullName localDisplayName fullName @@ -652,6 +520,3 @@ highlight' = highlight styleTime :: String -> StyledString styleTime = Styled [SetColor Foreground Vivid Black] - -clientVersionInfo :: [StyledString] -clientVersionInfo = [plain versionStr, plain updateStr] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 2a4d4e05b5..a8aff63628 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -84,7 +84,7 @@ testAddContact = -- 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" testGroup :: IO () @@ -168,7 +168,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 +293,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 +316,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" @@ -340,7 +340,7 @@ testGroupDelete = ] 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" @@ -822,7 +822,7 @@ cc #> cmd = do cc <# cmd 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 From ce3d7f21b0e17cb4141986ccb062f876c6310bdd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 24 Jan 2022 19:42:41 +0000 Subject: [PATCH 10/82] haskell nix flake CI (#222) * Adds preliminary flake This nix flake should be enough to try and build an android library. * add sha256map * bump * bump index-state Co-authored-by: Moritz Angermann --- cabal.project | 5 + direct-sqlite-2.3.26.patch | 15 ++ flake.lock | 341 +++++++++++++++++++++++++++++++++++++ flake.nix | 165 ++++++++++++++++++ sha256map.nix | 5 + update-sha256.awk | 23 +++ 6 files changed, 554 insertions(+) create mode 100644 direct-sqlite-2.3.26.patch create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 sha256map.nix create mode 100644 update-sha256.awk diff --git a/cabal.project b/cabal.project index 1de1eae020..39619484b6 100644 --- a/cabal.project +++ b/cabal.project @@ -9,3 +9,8 @@ source-repository-package type: git location: git://github.com/simplex-chat/haskell-terminal.git 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/sha256map.nix b/sha256map.nix new file mode 100644 index 0000000000..71494a7c5b --- /dev/null +++ b/sha256map.nix @@ -0,0 +1,5 @@ +{ + "git://github.com/simplex-chat/simplexmq.git"."b777a4fd93f888d549edf1877583fb7fc0e0196f" = "0cnbc9swdzb29j3pv4z64w26sq8dsp4ixnnv5bbf5k6dz9bwl9zm"; + "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; + "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; +} 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 "}" +} From b86f034c0b2028f4c8689d9f9a38a38113b91c84 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 24 Jan 2022 22:52:55 +0000 Subject: [PATCH 11/82] update C api to return JSON messages via chat_recv_msg (#224) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 63 +++++++++++++--------- src/Simplex/Chat/Mobile.hs | 43 ++++++++++----- 2 files changed, 70 insertions(+), 36 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7ff35241c8..9ccab2ff65 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,14 +7,16 @@ objects = { /* Begin PBXBuildFile section */ - 5C764E61279C70E0000C6508 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5D279C70DE000C6508 /* libgmp.a */; }; - 5C764E62279C70E0000C6508 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5D279C70DE000C6508 /* libgmp.a */; }; - 5C764E63279C70E0000C6508 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5E279C70DE000C6508 /* libgmpxx.a */; }; - 5C764E64279C70E0000C6508 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5E279C70DE000C6508 /* libgmpxx.a */; }; - 5C764E65279C70E0000C6508 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5F279C70DE000C6508 /* libffi.a */; }; - 5C764E66279C70E0000C6508 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E5F279C70DE000C6508 /* libffi.a */; }; - 5C764E67279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */; }; - 5C764E68279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */; }; + 5C1AEB82279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */; }; + 5C1AEB83279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */; }; + 5C1AEB84279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */; }; + 5C1AEB85279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */; }; + 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; + 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; + 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; + 5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; + 5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; }; + 5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* 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 */; }; @@ -57,10 +59,11 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 5C764E5D279C70DE000C6508 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C764E5E279C70DE000C6508 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C764E5F279C70DE000C6508 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a"; sourceTree = ""; }; + 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a"; sourceTree = ""; }; + 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a"; sourceTree = ""; }; + 5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C1AEB81279F4A6400247F08 /* 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 = ""; }; @@ -87,12 +90,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C764E67279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C764E63279C70E0000C6508 /* libgmpxx.a in Frameworks */, - 5C764E65279C70E0000C6508 /* libffi.a in Frameworks */, - 5C764E61279C70E0000C6508 /* libgmp.a in Frameworks */, + 5C1AEB84279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, + 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */, + 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */, + 5C1AEB82279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */, + 5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -100,12 +104,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C764E68279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a in Frameworks */, 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C764E64279C70E0000C6508 /* libgmpxx.a in Frameworks */, - 5C764E66279C70E0000C6508 /* libffi.a in Frameworks */, - 5C764E62279C70E0000C6508 /* libgmp.a in Frameworks */, + 5C1AEB85279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, + 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */, + 5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */, + 5C1AEB83279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */, + 5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -129,10 +134,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C764E5F279C70DE000C6508 /* libffi.a */, - 5C764E5D279C70DE000C6508 /* libgmp.a */, - 5C764E5E279C70DE000C6508 /* libgmpxx.a */, - 5C764E60279C70E0000C6508 /* libHSsimplex-chat-1.0.1-756RvUPisyT7gsYObFpxWS-ghc8.10.7.a */, + 5C1AEB7F279F4A6400247F08 /* libffi.a */, + 5C1AEB80279F4A6400247F08 /* libgmp.a */, + 5C1AEB81279F4A6400247F08 /* libgmpxx.a */, + 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */, + 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */, ); path = Libraries; sourceTree = ""; @@ -572,6 +578,8 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Libraries", + "$(PROJECT_DIR)/Libraries/ios", + "$(PROJECT_DIR)/Libraries/sim", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; @@ -610,6 +618,8 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Libraries", + "$(PROJECT_DIR)/Libraries/ios", + "$(PROJECT_DIR)/Libraries/sim", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; @@ -630,6 +640,7 @@ 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; @@ -646,6 +657,8 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Libraries", + "$(PROJECT_DIR)/Libraries/ios", + "$(PROJECT_DIR)/Libraries/sim", ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; @@ -682,6 +695,8 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Libraries", + "$(PROJECT_DIR)/Libraries/ios", + "$(PROJECT_DIR)/Libraries/sim", ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 8b3fc64c98..0f9940bcc0 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -9,20 +10,23 @@ import Control.Concurrent (forkIO) import Control.Concurrent.STM import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson ((.=)) +import Data.Aeson (ToJSON (..), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE +import qualified Data.ByteString.Base64.URL as U 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 import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Types import Simplex.Chat.View +import Simplex.Messaging.Protocol (CorrId (..)) foreign export ccall "chat_init_store" cChatInitStore :: CString -> IO (StablePtr ChatStore) @@ -34,7 +38,7 @@ foreign export ccall "chat_start" cChatStart :: StablePtr ChatStore -> IO (Stabl foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString -foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CString +foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString -- | creates or connects to chat store cChatInitStore :: CString -> IO (StablePtr ChatStore) @@ -63,7 +67,7 @@ cChatSendCmd cPtr cCmd = do newCString =<< chatSendCmd c cmd -- | receive message from chat (blocking) -cChatRecvMsg :: StablePtr ChatController -> IO CString +cChatRecvMsg :: StablePtr ChatController -> IO CJSONString cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCString mobileChatOpts :: ChatOpts @@ -115,18 +119,33 @@ chatStart ChatStore {dbFilePrefix, chatStore} = do pure cc chatSendCmd :: ChatController -> String -> IO JSONString -chatSendCmd cc s = crToJSON <$> runReaderT (execChatCommand s) cc +chatSendCmd cc s = crToJSON (CorrId "") <$> runReaderT (execChatCommand s) cc -chatRecvMsg :: ChatController -> IO String -chatRecvMsg ChatController {outputQ} = serializeChatResponse . snd <$> atomically (readTBQueue outputQ) +chatRecvMsg :: ChatController -> IO JSONString +chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) + where + json (corrId, resp) = crToJSON corrId resp jsonObject :: J.Series -> JSONString jsonObject = LB.unpack . JE.encodingToLazyByteString . J.pairs -crToJSON :: ChatResponse -> JSONString -crToJSON = \case - CRUserProfile p -> o "profile" $ J.object ["profile" .= p] - r -> o "terminal" $ J.object ["response" .= serializeChatResponse r] +crToJSON :: CorrId -> ChatResponse -> JSONString +crToJSON corrId = LB.unpack . J.encode . crToAPI corrId + +crToAPI :: CorrId -> ChatResponse -> APIResponse +crToAPI (CorrId cId) = \case + CRUserProfile p -> api "profile" $ J.object ["profile" .= p] + r -> api "terminal" $ J.object ["output" .= serializeChatResponse r] where - o :: String -> J.Value -> JSONString - o tp params = jsonObject ("type" .= tp <> "params" .= params) + corr = if B.null cId then Nothing else Just . B.unpack $ U.encode cId + api tag args = APIResponse {corr, tag, args} + +data APIResponse = APIResponse + { -- | optional correlation ID for async command responses + corr :: Maybe String, + tag :: String, + args :: J.Value + } + deriving (Generic) + +instance ToJSON APIResponse where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} From 6cf23f1fd171036456d5e72b18a84b6a3d608d52 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Wed, 26 Jan 2022 16:18:27 +0400 Subject: [PATCH 12/82] chat items (#223) * add chat items migration * chat and chat items types * queries draft * ChatInfo with optional ChatItem * schema adjustments * flat schema and queries * refactor ChatResponse using ChatItem types * schema adjustments * refactor GroupInfo to include GroupMember of the user * remove Message * createNewChatItem, sendDirectChatItem * refactor to use GroupInfo in Chat type and all ChatResponses * replace ContactName with Contact in some ChatResponse constructors * remove Group selectors * minor correction * refactor * refactor 2 * nullable created_by_msg_id * remove normalized schema and queries * ON DELETE CASCADE / SET NULL * CIContent to Text * files chat_item_id * fix * apply ciContentToText * queries folder * refactor * moar refactor Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 425 ++++++++++-------- src/Simplex/Chat/Controller.hs | 70 ++- src/Simplex/Chat/Messages.hs | 160 ++++++- .../M20220122_pending_group_messages.hs | 2 - .../Chat/Migrations/M20220125_chat_items.hs | 35 ++ .../getChatInfoListDirect.sql | 39 ++ .../getChatInfoListGroup.sql | 45 ++ .../chat_item_queries/getChatItemsMixed.sql | 44 ++ .../getDirectChatItemList.sql | 32 ++ .../getGroupChatItemList.sql | 38 ++ src/Simplex/Chat/Protocol.hs | 26 +- src/Simplex/Chat/Store.hs | 381 ++++++++++------ src/Simplex/Chat/Types.hs | 102 ++++- src/Simplex/Chat/View.hs | 149 +++--- 15 files changed, 1065 insertions(+), 484 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20220125_chat_items.hs create mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql create mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql create mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql create mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql create mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a46f972ce0..17e5cc734d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -26,6 +26,7 @@ library Simplex.Chat.Messages Simplex.Chat.Migrations.M20220101_initial Simplex.Chat.Migrations.M20220122_pending_group_messages + Simplex.Chat.Migrations.M20220125_chat_items Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ba0df44512..cab1767d05 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -31,11 +31,11 @@ import Data.Int (Int64) import Data.List (find) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (isJust, mapMaybe) +import Data.Maybe (fromJust, isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (UTCTime, getCurrentTime) import Data.Time.LocalTime (utcToLocalZonedTime) import Data.Word (Word32) import Simplex.Chat.Controller @@ -179,25 +179,26 @@ processChatCommand user@User {userId, profile} = \case pure $ CRContactRequestRejected cName SendMessage cName msg -> do contact <- withStore $ \st -> getContact st userId cName - let msgContent = MCText $ safeDecodeUtf8 msg - meta <- liftIO . mkChatMsgMeta =<< sendDirectMessage (contactConn contact) (XMsgNew msgContent) + let mc = MCText $ safeDecodeUtf8 msg + ci <- sendDirectChatItem userId contact (XMsgNew mc) (CIMsgContent mc) setActive $ ActiveC cName - pure $ CRSentMessage cName msgContent meta + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci NewGroup gProfile -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) AddMember gName cName memRole -> do + -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \st -> (,) <$> getGroup st user gName <*> getContact st userId cName - let Group {groupId, groupProfile, membership, members} = group + 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) + when (memberStatus membership == GSMemInvited) $ chatError (CEGroupNotJoined gInfo) unless (memberActive membership) $ chatError CEGroupMemberNotActive let sendInvitation memberId cReq = do void . sendDirectMessage (contactConn contact) $ XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile setActive $ ActiveG gName - pure $ CRSentGroupInvitation gName cName + pure $ CRSentGroupInvitation gInfo contact case contactMember contact members of Nothing -> do gVar <- asks idsDrg @@ -208,20 +209,20 @@ processChatCommand user@User {userId, profile} = \case | memberStatus == GSMemInvited -> withStore (\st -> getMemberInvitation st user groupMemberId) >>= \case Just cReq -> sendInvitation memberId cReq - Nothing -> chatError $ CEGroupCantResendInvitation gName cName + Nothing -> chatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> chatError $ CEGroupDuplicateMember cName JoinGroup gName -> do - ReceivedGroupInvitation {fromMember, userMember, connRequest} <- withStore $ \st -> getGroupInvitation st user gName + ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \st -> getGroupInvitation st user gName procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (userMember :: GroupMember) + 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 userMember GSMemAccepted - pure $ CRUserAcceptedGroupSent gName + updateGroupMemberStatus st userId (membership g) GSMemAccepted + pure $ CRUserAcceptedGroupSent g MemberRole _gName _cName _mRole -> chatError $ CECommandError "unsupported" RemoveMember gName cName -> do - Group {membership, members} <- withStore $ \st -> getGroup st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName case find ((== cName) . (localDisplayName :: GroupMember -> ContactName)) members of Nothing -> chatError $ CEGroupMemberNotFound cName Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus} -> do @@ -231,16 +232,16 @@ processChatCommand user@User {userId, profile} = \case when (mStatus /= GSMemInvited) . void . sendGroupMessage members $ XGrpMemDel mId deleteMemberConnection m withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved - pure $ CRUserDeletedMember gName m + pure $ CRUserDeletedMember gInfo m LeaveGroup gName -> do - Group {membership, members} <- withStore $ \st -> getGroup st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName procCmd $ do void $ sendGroupMessage members XGrpLeave mapM_ deleteMemberConnection members withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft - pure $ CRLeftMemberUser gName + pure $ CRLeftMemberUser gInfo DeleteGroup gName -> do - g@Group {membership, members} <- withStore $ \st -> getGroup st user gName + g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroup st user gName let s = memberStatus membership canDelete = memberRole (membership :: GroupMember) == GROwner @@ -250,17 +251,16 @@ processChatCommand user@User {userId, profile} = \case when (memberActive membership) . void $ sendGroupMessage members XGrpDel mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g - pure $ CRGroupDeletedUser gName + pure $ CRGroupDeletedUser gInfo ListMembers gName -> CRGroupMembers <$> withStore (\st -> getGroup st user gName) - ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` userId) + ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` user) SendGroupMessage gName msg -> do - -- TODO save pending message delivery for members without connections - Group {members, membership} <- withStore $ \st -> getGroup st user gName + group@(Group gInfo@GroupInfo {membership} _) <- withStore $ \st -> getGroup st user gName unless (memberActive membership) $ chatError CEGroupMemberUserRemoved - let msgContent = MCText $ safeDecodeUtf8 msg - meta <- liftIO . mkChatMsgMeta =<< sendGroupMessage members (XMsgNew msgContent) + let mc = MCText $ safeDecodeUtf8 msg + ci <- sendGroupChatItem userId group (XMsgNew mc) (CIMsgContent mc) setActive $ ActiveG gName - pure $ CRSentGroupMessage gName msgContent meta + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci SendFile cName f -> do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContact st userId cName @@ -268,27 +268,28 @@ processChatCommand user@User {userId, profile} = \case let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq} SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize - meta <- liftIO . mkChatMsgMeta =<< sendDirectMessage (contactConn contact) (XFile fileInv) + ci <- sendDirectChatItem userId contact (XFile fileInv) (CISndFileInvitation fileId f) + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci setActive $ ActiveC cName - pure $ CRSentFileInvitation cName fileId f meta + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci SendGroupFile gName f -> do (fileSize, chSize) <- checkSndFile f - group@Group {members, membership} <- withStore $ \st -> getGroup st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName unless (memberActive membership) $ chatError 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 setActive $ ActiveG gName -- this is a hack as we have multiple direct messages instead of one per group - chatTs <- liftIO getCurrentTime - localChatTs <- liftIO $ utcToLocalZonedTime chatTs - let meta = ChatMsgMeta {msgId = 0, chatTs, localChatTs, createdAt = chatTs} - pure $ CRSentGroupFileInvitation gName fileId f meta + let ciContent = CISndFileInvitation fileId f + ciMeta@CIMetaProps{itemId} <- saveChatItem userId (CDSndGroup gInfo) Nothing ciContent + withStore $ \st -> updateFileTransferChatItemId st fileId itemId + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ SndGroupChatItem (CISndMeta ciMeta) ciContent ReceiveFile fileId filePath_ -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId unless (fileStatus == RFSNew) . chatError $ CEFileAlreadyReceiving fileName @@ -329,15 +330,15 @@ processChatCommand user@User {userId, profile} = \case procCmd :: m ChatResponse -> m ChatResponse procCmd a = do a - -- ! below code would make command responses asynchronous where they can be slow - -- ! in View.hs `r'` should be defined as `id` in this case - -- gVar <- asks idsDrg - -- corrId <- liftIO $ CorrId <$> randomBytes gVar 8 - -- q <- asks outputQ - -- void . forkIO $ atomically . writeTBQueue q =<< - -- (corrId,) <$> (a `catchError` (pure . CRChatError)) - -- pure $ CRCommandAccepted corrId - -- a corrId + -- ! below code would make command responses asynchronous where they can be slow + -- ! in View.hs `r'` should be defined as `id` in this case + -- gVar <- asks idsDrg + -- corrId <- liftIO $ CorrId <$> randomBytes gVar 8 + -- q <- asks outputQ + -- void . forkIO $ atomically . writeTBQueue q =<< + -- (corrId,) <$> (a `catchError` (pure . CRChatError)) + -- pure $ CRCommandAccepted corrId + -- a corrId connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () connect cReq msg = do connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg @@ -382,11 +383,6 @@ processChatCommand user@User {userId, profile} = \case f = filePath `combine` (name <> suffix <> ext) in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) -mkChatMsgMeta :: Message -> IO ChatMsgMeta -mkChatMsgMeta Message {msgId, chatTs, createdAt} = do - localChatTs <- utcToLocalZonedTime chatTs - pure ChatMsgMeta {msgId, chatTs, localChatTs, createdAt} - agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () agentSubscriber = do q <- asks $ subQ . smpAgent @@ -409,11 +405,11 @@ subscribeUserConnections = void . runExceptT $ do where subscribeContacts user = do contacts <- withStore (`getUserContacts` user) - forM_ contacts $ \ct@Contact {localDisplayName = c} -> - (subscribe (contactConnId ct) >> toView (CRContactSubscribed c)) `catchError` (toView . CRContactSubError c) + forM_ contacts $ \ct -> + (subscribe (contactConnId ct) >> toView (CRContactSubscribed ct)) `catchError` (toView . CRContactSubError ct) subscribeGroups user = 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 toView $ CRGroupInvitation g @@ -425,14 +421,14 @@ subscribeUserConnections = void . runExceptT $ do else toView $ CRGroupRemoved g else do forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) -> - subscribe cId `catchError` (toView . CRMemberSubError gn c) + subscribe cId `catchError` (toView . CRMemberSubError g c) toView $ CRGroupSubscribed g subscribeFiles user = do withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile where - subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId} = do - subscribe agentConnId `catchError` (toView . CRSndFileSubError ft) + subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId cId} = do + subscribe cId `catchError` (toView . CRSndFileSubError ft) void . forkIO $ do threadDelay 1000000 l <- asks chatLock @@ -446,8 +442,8 @@ subscribeUserConnections = void . runExceptT $ do RFSConnected fInfo -> resume fInfo _ -> pure () where - resume RcvFileInfo {agentConnId} = - subscribe agentConnId `catchError` (toView . CRRcvFileSubError ft) + resume RcvFileInfo {agentConnId = AgentConnId cId} = + subscribe cId `catchError` (toView . CRRcvFileSubError ft) subscribePendingConnections user = do cs <- withStore (`getPendingConnections` user) subscribeConns cs `catchError` \_ -> pure () @@ -463,14 +459,14 @@ subscribeUserConnections = void . runExceptT $ do 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 + acEntity <- withStore $ \st -> getConnectionEntity 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 + withStore $ \st -> updateConnectionStatus st (fromConnection acEntity) status + case acEntity of + 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 -> @@ -478,8 +474,8 @@ 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} = + isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool + isMember memId GroupInfo {membership} members = sameMemberId memId membership || isJust (find (sameMemberId memId) members) contactIsReady :: Contact -> Bool @@ -515,19 +511,19 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do -- TODO add debugging output _ -> pure () Just ct@Contact {localDisplayName = c} -> case agentMsg of - MSG meta msgBody -> do - (chatMsgEvent, msg) <- saveRcvMSG conn meta msgBody - withAckMessage agentConnId meta $ + MSG msgMeta msgBody -> do + (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody + withAckMessage agentConnId msgMeta $ case chatMsgEvent of - XMsgNew mc -> newContentMessage c msg mc meta - XFile fInv -> processFileInvitation ct msg fInv meta + 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 @@ -555,21 +551,21 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do 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 -> sentMsgDeliveryEvent conn msgId END -> do - toView $ CRContactAnotherClient c + toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c DOWN -> do - toView $ CRContactDisconnected c + toView $ CRContactDisconnected ct showToast (c <> "> ") "disconnected" UP -> do - toView $ CRContactSubscribed c + toView $ CRContactSubscribed ct showToast (c <> "> ") "is active" setActive $ ActiveC c -- TODO print errors @@ -578,8 +574,8 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do -- 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 @@ -596,7 +592,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" @@ -612,7 +607,7 @@ 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) $ @@ -620,14 +615,14 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do sendPendingGroupMessages m conn case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup gName + toView $ CRUserJoinedGroup gInfo setActive $ ActiveG gName showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do - toView $ CRJoinedGroupMember gName m + toView $ CRJoinedGroupMember gInfo m setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" - intros <- withStore $ \st -> createIntroductions st group m + 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 @@ -637,27 +632,27 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do -- 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, msg) <- saveRcvMSG conn meta msgBody - withAckMessage agentConnId meta $ + MSG msgMeta msgBody -> do + (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody + withAckMessage agentConnId msgMeta $ case chatMsgEvent of - XMsgNew mc -> newGroupContentMessage gName m msg mc meta - XFile fInv -> processGroupFileInvitation gName m msg fInv meta - 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 @@ -780,11 +775,12 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do cancelRcvFileTransfer ft chatError $ CEFileRcvChunk err - notifyMemberConnected :: GroupName -> GroupMember -> m () - notifyMemberConnected gName m@GroupMember {localDisplayName} = do - toView $ CRConnectedToGroupMember 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 @@ -806,45 +802,49 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do messageError :: Text -> m () messageError = toView . CRMessageError "error" - newContentMessage :: ContactName -> Message -> MsgContent -> MsgMeta -> m () - newContentMessage c msg mc MsgMeta {integrity} = do - meta <- liftIO $ mkChatMsgMeta msg - toView $ CRReceivedMessage c meta mc integrity + newContentMessage :: Contact -> MsgContent -> MessageId -> MsgMeta -> m () + newContentMessage ct@Contact {localDisplayName = c} mc msgId msgMeta = do + ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIMsgContent mc) + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci showToast (c <> "> ") $ msgContentText mc setActive $ ActiveC c - newGroupContentMessage :: GroupName -> GroupMember -> Message -> MsgContent -> MsgMeta -> m () - newGroupContentMessage gName GroupMember {localDisplayName = c} msg mc MsgMeta {integrity} = do - meta <- liftIO $ mkChatMsgMeta msg - toView $ CRReceivedGroupMessage gName c meta mc integrity - showToast ("#" <> gName <> " " <> c <> "> ") $ msgContentText mc - 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 (CIMsgContent mc) + toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci + let g = groupName gInfo + showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc + setActive $ ActiveG g - processFileInvitation :: Contact -> Message -> FileInvitation -> MsgMeta -> m () - processFileInvitation contact@Contact {localDisplayName = c} msg fInv MsgMeta {integrity} = 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 - meta <- liftIO $ mkChatMsgMeta msg - toView $ CRReceivedFileInvitation c meta ft integrity + 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 showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c - processGroupFileInvitation :: GroupName -> GroupMember -> Message -> FileInvitation -> MsgMeta -> m () - processGroupFileInvitation gName m@GroupMember {localDisplayName = c} msg fInv MsgMeta {integrity} = 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 - meta <- liftIO $ mkChatMsgMeta msg - toView $ CRReceivedGroupFileInvitation gName c meta ft integrity - 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 + 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 - toView $ CRReceivedGroupInvitation group c memRole + 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" xInfo :: Contact -> Profile -> m () @@ -887,53 +887,53 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do -- 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 - toView $ CRJoinedGroupMemberConnecting 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} 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 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 @@ -941,14 +941,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 - toView $ CRDeletedMemberUser 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 @@ -958,32 +958,32 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do else do deleteMemberConnection member withStore $ \st -> updateGroupMemberStatus st userId member GSMemRemoved - toView $ CRDeletedMember gName m 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 - toView $ CRLeftMember gName m + toView $ CRLeftMember gInfo m - xGrpDel :: GroupName -> GroupMember -> m () - xGrpDel gName m@GroupMember {memberRole} = do + xGrpDel :: GroupInfo -> GroupMember -> m () + xGrpDel gInfo m@GroupMember {memberRole} = do when (memberRole /= GROwner) $ chatError 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 - toView $ CRGroupDeleted gName m + toView $ CRGroupDeleted gInfo m parseChatMessage :: ByteString -> Either ChatError ChatMessage parseChatMessage = first ChatErrorMessage . 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 @@ -993,12 +993,12 @@ sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId} = deleteSndFileChunks st 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 @@ -1068,19 +1068,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 @@ -1098,18 +1098,18 @@ 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 Message +sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> m MessageId sendDirectMessage conn chatMsgEvent = do - msg@Message {msgId, msgBody} <- createSndMessage chatMsgEvent + (msgId, msgBody) <- createSndMessage chatMsgEvent deliverMessage conn msgBody msgId - pure msg + pure msgId -createSndMessage :: ChatMonad m => ChatMsgEvent -> m Message +createSndMessage :: ChatMonad m => ChatMsgEvent -> m (MessageId, MsgBody) createSndMessage chatMsgEvent = do - chatTs <- liftIO getCurrentTime let msgBody = directMessage chatMsgEvent - newMsg = NewMessage {direction = MDSnd, cmEventTag = toCMEventTag chatMsgEvent, msgBody, chatTs} - withStore $ \st -> createNewMessage st newMsg + 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} @@ -1120,23 +1120,23 @@ deliverMessage Connection {connId, agentConnId} msgBody msgId = do let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} withStore $ \st -> createSndMsgDelivery st sndMsgDelivery msgId -sendGroupMessage :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> m Message +sendGroupMessage :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> m MessageId sendGroupMessage members chatMsgEvent = sendGroupMessage' members chatMsgEvent Nothing $ pure () -sendXGrpMemInv :: ChatMonad m => GroupMember -> ChatMsgEvent -> Int64 -> m Message +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 Message +sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Maybe Int64 -> m () -> m MessageId sendGroupMessage' members chatMsgEvent introId_ postDeliver = do - msg@Message {msgId, msgBody} <- createSndMessage chatMsgEvent + (msgId, msgBody) <- createSndMessage chatMsgEvent for_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} -> case memberConn m of Nothing -> withStore $ \st -> createPendingGroupMessage st groupMemberId msgId introId_ Just conn -> deliverMessage conn msgBody msgId >> postDeliver - pure msg + pure msgId sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m () sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do @@ -1149,16 +1149,67 @@ sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do Nothing -> chatError $ CEGroupMemberIntroNotFound localDisplayName Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) -saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m (ChatMsgEvent, Message) +saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m (MessageId, ChatMsgEvent) saveRcvMSG Connection {connId} agentMsgMeta msgBody = do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody let agentMsgId = fst $ recipient agentMsgMeta - chatTs = snd $ broker agentMsgMeta cmEventTag = toCMEventTag chatMsgEvent - newMsg = NewMessage {direction = MDRcv, cmEventTag, chatTs, msgBody} + newMsg = NewMessage {direction = MDRcv, cmEventTag, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} - msg <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery - pure (chatMsgEvent, msg) + 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 + ciMeta <- saveChatItem userId (CDDirect contact) (Just msgId) ciContent + pure $ DirectChatItem (CISndMeta 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 + ciMeta <- saveChatItem userId (CDSndGroup g) (Just msgId) ciContent + pure $ SndGroupChatItem (CISndMeta ciMeta) ciContent + +saveRcvDirectChatItem :: ChatMonad m => UserId -> Contact -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTDirect 'MDRcv) +saveRcvDirectChatItem userId ct msgId MsgMeta {integrity} ciContent = do + ciMeta <- saveChatItem userId (CDDirect ct) (Just msgId) ciContent + pure $ DirectChatItem (CIRcvMeta ciMeta integrity) ciContent + +saveRcvGroupChatItem :: ChatMonad m => UserId -> GroupInfo -> GroupMember -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTGroup 'MDRcv) +saveRcvGroupChatItem userId g m msgId MsgMeta {integrity} ciContent = do + ciMeta <- saveChatItem userId (CDRcvGroup g m) (Just msgId) ciContent + pure $ RcvGroupChatItem m (CIRcvMeta ciMeta integrity) ciContent + +saveChatItem :: ChatMonad m => UserId -> ChatDirection c d -> Maybe MessageId -> CIContent d -> m CIMetaProps +saveChatItem userId chatDirection msgId_ ciContent = do + ci@NewChatItem {itemTs, createdAt} <- mkNewChatItem msgId_ MDRcv Nothing ciContent + ciId <- withStore $ \st -> createNewChatItem st userId chatDirection ci + liftIO $ mkCIMetaProps ciId itemTs createdAt + +mkNewChatItem :: ChatMonad m => Maybe MessageId -> MsgDirection -> Maybe UTCTime -> CIContent d -> m (NewChatItem d) +mkNewChatItem createdByMsgId_ itemSent brokerTs_ itemContent = do + (itemTs, createdAt) <- timestamps + pure + NewChatItem + { createdByMsgId_, + itemSent, + itemTs, + itemContent, + itemText = ciContentToText itemContent, + createdAt + } + where + timestamps = do + createdAt <- liftIO getCurrentTime + if isJust brokerTs_ + then pure (fromJust brokerTs_, createdAt) -- if rcv use brokerTs + else pure (createdAt, createdAt) -- if snd use createdAt + +mkCIMetaProps :: ChatItemId -> ChatItemTs -> UTCTime -> IO CIMetaProps +mkCIMetaProps itemId itemTs createdAt = do + localItemTs <- utcToLocalZonedTime itemTs + pure CIMetaProps {itemId, itemTs, localItemTs, createdAt} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn@Connection {agentConnId} confId msg = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 692f917349..afff5ae9aa 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -17,7 +17,6 @@ import Data.Map.Strict (Map) import Data.Text (Text) import Numeric.Natural import Simplex.Chat.Messages -import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) @@ -103,26 +102,19 @@ data ChatCommand deriving (Show) data ChatResponse - = CRSentMessage ContactName MsgContent ChatMsgMeta - | CRSentGroupMessage GroupName MsgContent ChatMsgMeta - | CRSentFileInvitation ContactName FileTransferId FilePath ChatMsgMeta - | CRSentGroupFileInvitation GroupName FileTransferId FilePath ChatMsgMeta - | CRReceivedMessage ContactName ChatMsgMeta MsgContent MsgIntegrity - | CRReceivedGroupMessage GroupName ContactName ChatMsgMeta MsgContent MsgIntegrity - | CRReceivedFileInvitation ContactName ChatMsgMeta RcvFileTransfer MsgIntegrity - | CRReceivedGroupFileInvitation GroupName ContactName ChatMsgMeta RcvFileTransfer MsgIntegrity + = CRNewChatItem AChatItem | CRCommandAccepted CorrId | CRChatHelp HelpSection | CRWelcome User - | CRGroupCreated Group + | CRGroupCreated GroupInfo | CRGroupMembers Group | CRContactsList [Contact] | CRUserContactLink ConnReqContact - | CRContactRequestRejected ContactName - | CRUserAcceptedGroupSent GroupName - | CRUserDeletedMember GroupName GroupMember + | CRContactRequestRejected ContactName -- TODO + | CRUserAcceptedGroupSent GroupInfo + | CRUserDeletedMember GroupInfo GroupMember | CRGroupsList [GroupInfo] - | CRSentGroupInvitation GroupName ContactName + | CRSentGroupInvitation GroupInfo Contact | CRFileTransferStatus (FileTransfer, [Integer]) | CRUserProfile Profile | CRUserProfileNoChange @@ -132,13 +124,13 @@ data ChatResponse | CRSentInvitation | CRContactUpdated {fromContact :: Contact, toContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} - | CRContactDeleted ContactName + | CRContactDeleted ContactName -- TODO | CRUserContactLinkCreated ConnReqContact | CRUserContactLinkDeleted - | CRReceivedContactRequest ContactName Profile - | CRAcceptingContactRequest ContactName - | CRLeftMemberUser GroupName - | CRGroupDeletedUser GroupName + | CRReceivedContactRequest ContactName Profile -- TODO what is the entity here? + | CRAcceptingContactRequest ContactName -- TODO + | CRLeftMemberUser GroupInfo + | CRGroupDeletedUser GroupInfo | CRRcvFileAccepted RcvFileTransfer FilePath | CRRcvFileAcceptedSndCancelled RcvFileTransfer | CRRcvFileStart RcvFileTransfer @@ -152,24 +144,24 @@ data ChatResponse | CRSndGroupFileCancelled [SndFileTransfer] | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRContactConnected Contact - | CRContactAnotherClient ContactName - | CRContactDisconnected ContactName - | CRContactSubscribed ContactName - | CRContactSubError ContactName ChatError - | CRGroupInvitation Group - | CRReceivedGroupInvitation Group ContactName GroupMemberRole - | CRUserJoinedGroup GroupName - | CRJoinedGroupMember GroupName GroupMember - | CRJoinedGroupMemberConnecting {group :: GroupName, hostMember :: GroupMember, member :: GroupMember} - | CRConnectedToGroupMember GroupName GroupMember - | CRDeletedMember {group :: GroupName, byMember :: GroupMember, deletedMember :: GroupMember} - | CRDeletedMemberUser GroupName GroupMember - | CRLeftMember GroupName GroupMember - | CRGroupEmpty Group - | CRGroupRemoved Group - | CRGroupDeleted GroupName GroupMember - | CRMemberSubError GroupName ContactName ChatError - | CRGroupSubscribed Group + | CRContactAnotherClient Contact + | CRContactDisconnected Contact + | CRContactSubscribed Contact + | CRContactSubError Contact ChatError + | CRGroupInvitation GroupInfo + | CRReceivedGroupInvitation GroupInfo Contact GroupMemberRole + | CRUserJoinedGroup GroupInfo + | CRJoinedGroupMember GroupInfo GroupMember + | CRJoinedGroupMemberConnecting {group :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CRConnectedToGroupMember GroupInfo GroupMember + | CRDeletedMember {group :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} + | CRDeletedMemberUser GroupInfo GroupMember + | CRLeftMember GroupInfo GroupMember + | CRGroupEmpty GroupInfo + | CRGroupRemoved GroupInfo + | CRGroupDeleted GroupInfo GroupMember + | CRMemberSubError GroupInfo ContactName ChatError -- TODO Contact? or GroupMember? + | CRGroupSubscribed GroupInfo | CRSndFileSubError SndFileTransfer ChatError | CRRcvFileSubError RcvFileTransfer ChatError | CRUserContactLinkSubscribed @@ -193,12 +185,12 @@ data ChatErrorType | CEGroupContactRole ContactName | CEGroupDuplicateMember ContactName | CEGroupDuplicateMemberId - | CEGroupNotJoined GroupName + | CEGroupNotJoined GroupInfo | CEGroupMemberNotActive | CEGroupMemberUserRemoved | CEGroupMemberNotFound ContactName | CEGroupMemberIntroNotFound ContactName - | CEGroupCantResendInvitation GroupName ContactName + | CEGroupCantResendInvitation GroupInfo ContactName | CEGroupInternal String | CEFileNotFound String | CEFileAlreadyReceiving String diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 99ce67a12d..2f86c8c2ed 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -8,16 +8,18 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages where -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON, ToJSON, (.=)) import qualified Data.Aeson as J 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) import Data.Time.Clock (UTCTime) import Data.Time.LocalTime (ZonedTime) @@ -28,28 +30,148 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), serializeMsgIntegrity) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgIntegrity, MsgMeta (..), serializeMsgIntegrity) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Protocol (MsgBody) +data ChatType = CTDirect | CTGroup + deriving (Show) + +data ChatInfo (c :: ChatType) where + DirectChat :: Contact -> ChatInfo 'CTDirect + GroupChat :: GroupInfo -> ChatInfo 'CTGroup + +deriving instance Show (ChatInfo c) + +type ChatItemData d = (CIMeta d, CIContent d) + +data ChatItem (c :: ChatType) (d :: MsgDirection) where + DirectChatItem :: CIMeta d -> CIContent d -> ChatItem 'CTDirect d + SndGroupChatItem :: CIMeta 'MDSnd -> CIContent 'MDSnd -> ChatItem 'CTGroup 'MDSnd + RcvGroupChatItem :: GroupMember -> CIMeta 'MDRcv -> CIContent 'MDRcv -> ChatItem 'CTGroup 'MDRcv + +deriving instance Show (ChatItem c d) + +data CChatItem c = forall d. CChatItem (SMsgDirection d) (ChatItem c d) + +deriving instance Show (CChatItem c) + +chatItemId :: ChatItem c d -> ChatItemId +chatItemId = \case + DirectChatItem (CISndMeta CIMetaProps {itemId}) _ -> itemId + DirectChatItem (CIRcvMeta CIMetaProps {itemId} _) _ -> itemId + SndGroupChatItem (CISndMeta CIMetaProps {itemId}) _ -> itemId + RcvGroupChatItem _ (CIRcvMeta CIMetaProps {itemId} _) _ -> itemId + +data ChatDirection (c :: ChatType) (d :: MsgDirection) where + CDDirect :: Contact -> ChatDirection 'CTDirect d + CDSndGroup :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd + CDRcvGroup :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + +data NewChatItem d = NewChatItem + { createdByMsgId_ :: Maybe MessageId, + itemSent :: MsgDirection, + itemTs :: ChatItemTs, + itemContent :: CIContent d, + itemText :: Text, + createdAt :: UTCTime + } + deriving (Show) + +-- | type to show one chat with messages +data Chat c = Chat (ChatInfo c) [CChatItem c] + deriving (Show) + +-- | type to show the list of chats, with one last message in each +data AChatPreview = forall c. AChatPreview (SChatType c) (ChatInfo c) (Maybe (CChatItem c)) + +deriving instance Show AChatPreview + +-- | 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 + +data CIMeta (d :: MsgDirection) where + CISndMeta :: CIMetaProps -> CIMeta 'MDSnd + CIRcvMeta :: CIMetaProps -> MsgIntegrity -> CIMeta 'MDRcv + +deriving instance Show (CIMeta d) + +data CIMetaProps = CIMetaProps + { itemId :: ChatItemId, + itemTs :: ChatItemTs, + localItemTs :: ZonedTime, + createdAt :: UTCTime + } + deriving (Show) + +type ChatItemId = Int64 + +type ChatItemTs = UTCTime + +data CIContent (d :: MsgDirection) where + CIMsgContent :: MsgContent -> CIContent d + CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd + CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv + +deriving instance Show (CIContent d) + +instance ToField (CIContent d) where toField = toField . decodeLatin1 . LB.toStrict . J.encode + +instance ToJSON (CIContent d) where + toJSON = J.toJSON . ciContentToJSON + toEncoding = J.toEncoding . ciContentToJSON + +data CIContentJSON = CIContentJSON + { tag :: Text, + subTag :: Maybe Text, + args :: J.Value + } + deriving (Generic, FromJSON) + +instance ToJSON CIContentJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +ciContentToJSON :: CIContent d -> CIContentJSON +ciContentToJSON = \case + CIMsgContent mc -> o "content" "" $ J.object ["content" .= mc] + CISndFileInvitation fId fPath -> o "sndFile" "invitation" $ J.object ["fileId" .= fId, "filePath" .= fPath] + CIRcvFileInvitation ft -> o "rcvFile" "invitation" $ J.object ["fileTransfer" .= ft] + where + o tag "" args = CIContentJSON {tag, subTag = Nothing, args} + o tag st args = CIContentJSON {tag, subTag = Just st, args} + +ciContentToText :: CIContent d -> Text +ciContentToText = \case + CIMsgContent 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 + +data SChatType (c :: ChatType) where + SCTDirect :: SChatType 'CTDirect + SCTGroup :: SChatType 'CTGroup + +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, - chatTs :: UTCTime, msgBody :: MsgBody } deriving (Show) -data Message = Message - { msgId :: MessageId, - direction :: MsgDirection, - cmEventTag :: CMEventTag, - chatTs :: UTCTime, - msgBody :: MsgBody, - createdAt :: UTCTime - } - deriving (Show) - data PendingGroupMessage = PendingGroupMessage { msgId :: MessageId, cmEventTag :: CMEventTag, @@ -57,13 +179,7 @@ data PendingGroupMessage = PendingGroupMessage introId_ :: Maybe Int64 } -data ChatMsgMeta = ChatMsgMeta - { msgId :: MessageId, - chatTs :: UTCTime, - localChatTs :: ZonedTime, - createdAt :: UTCTime - } - deriving (Show) +type MessageId = Int64 data MsgDirection = MDRcv | MDSnd deriving (Show) @@ -72,6 +188,8 @@ 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 @@ -118,7 +236,7 @@ data MsgMetaJSON = MsgMetaJSON } deriving (Eq, Show, FromJSON, Generic) -instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions +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} = diff --git a/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs b/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs index 81e81c7d7a..c432b19f15 100644 --- a/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs +++ b/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs @@ -16,6 +16,4 @@ CREATE TABLE pending_group_messages ( group_member_intro_id INTEGER REFERENCES group_member_intros ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); - -ALTER TABLE messages ADD chat_ts TEXT; |] diff --git a/src/Simplex/Chat/Migrations/M20220125_chat_items.hs b/src/Simplex/Chat/Migrations/M20220125_chat_items.hs new file mode 100644 index 0000000000..38196e94d8 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220125_chat_items.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220125_chat_items where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220125_chat_items :: Query +m20220125_chat_items = + [sql| +CREATE TABLE chat_items ( -- mutable chat_items presented to user + 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, + UNIQUE (chat_item_id, message_id) +); + +ALTER TABLE files ADD COLUMN chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE; +|] diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql new file mode 100644 index 0000000000..bc5279d517 --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql @@ -0,0 +1,39 @@ +SELECT + c.contact_id, + cp.display_name, + cp.full_name, + cp.properties, + ci.chat_item_id, + ci.chat_msg_id, + ci.created_by_msg_id, + ci.item_sent, + ci.item_ts, + ci.item_deleted, + ci.item_text, + ci.item_content, + md.msg_delivery_id, + md.chat_ts, + md.agent_msg_meta, + mde.delivery_status, + mde.created_at +FROM contacts c +JOIN contact_profiles cp ON cp.contact_profile_id == c.contact_profile_id +JOIN ( + SELECT contact_id, chat_item_id, MAX(item_ts) MaxDate + FROM chat_items + WHERE item_deleted != 1 + GROUP BY contact_id, chat_item_id +) CIMaxDates ON CIMaxDates.contact_id = c.contact_id +LEFT JOIN chat_items ci ON ci.chat_item_id == CIMaxDates.chat_item_id + AND ci.item_ts == CIMaxDates.MaxDate +JOIN messages m ON m.message_id == ci.created_by_msg_id +JOIN msg_deliveries md ON md.message_id = m.message_id +JOIN ( + SELECT msg_delivery_id, MAX(created_at) MaxDate + FROM msg_delivery_events + GROUP BY msg_delivery_id +) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id +JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id + AND mde.created_at = MDEMaxDates.MaxDate +WHERE c.user_id = ? +ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql new file mode 100644 index 0000000000..e1fbd4db55 --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql @@ -0,0 +1,45 @@ +SELECT + g.group_id, + gp.display_name, + gp.full_name, + gp.properties, + gm.group_member_id, + cp.display_name, + cp.full_name, + cp.properties, + ci.chat_item_id, + ci.chat_msg_id, + ci.created_by_msg_id, + ci.item_sent, + ci.item_ts, + ci.item_deleted, + ci.item_text, + ci.item_content, + md.msg_delivery_id, + md.chat_ts, + md.agent_msg_meta, + mde.delivery_status, + mde.created_at +FROM groups g +JOIN group_profiles gp ON gp.group_profile_id == g.group_profile_id +JOIN ( + SELECT group_id, chat_item_id, MAX(item_ts) MaxDate + FROM chat_items + WHERE item_deleted != 1 + GROUP BY group_id, chat_item_id +) CIMaxDates ON CIMaxDates.group_id = g.group_id +LEFT JOIN chat_items ci ON ci.chat_item_id == CIMaxDates.chat_item_id + AND ci.item_ts == CIMaxDates.MaxDate +LEFT JOIN group_members ON gm.group_member_id == ci.group_member_id +JOIN contact_profiles cp ON cp.contact_profile_id == gm.contact_profile_id +JOIN messages m ON m.message_id == ci.created_by_msg_id +JOIN msg_deliveries md ON md.message_id = m.message_id +JOIN ( + SELECT msg_delivery_id, MAX(created_at) MaxDate + FROM msg_delivery_events + GROUP BY msg_delivery_id +) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id +JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id + AND mde.created_at = MDEMaxDates.MaxDate +WHERE c.user_id = ? +ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql new file mode 100644 index 0000000000..f537cee929 --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql @@ -0,0 +1,44 @@ +SELECT + c.contact_id, + cp.display_name, + cp.full_name, + cp.properties, + g.group_id, + gp.display_name, + gp.full_name, + gp.properties, + gm.group_member_id, + gmp.display_name, + gmp.full_name, + gmp.properties, + ci.chat_item_id, + ci.chat_msg_id, + ci.created_by_msg_id, + ci.item_sent, + ci.item_ts, + ci.item_deleted, + ci.item_text, + ci.item_content, + md.msg_delivery_id, + md.chat_ts, + md.agent_msg_meta, + mde.delivery_status, + mde.created_at +FROM chat_items ci +LEFT JOIN contacts c ON c.contact_id == ci.contact_id +JOIN contact_profiles cp ON cp.contact_profile_id == c.contact_profile_id +LEFT JOIN groups g ON g.group_id = ci.group_id +JOIN group_profiles gp ON gp.group_profile_id == g.group_profile_id +LEFT JOIN group_members ON gm.group_member_id == ci.group_member_id +JOIN contact_profiles gmp ON gmp.contact_profile_id == gm.contact_profile_id +JOIN messages m ON m.message_id == ci.created_by_msg_id +JOIN msg_deliveries md ON md.message_id = m.message_id +JOIN ( + SELECT msg_delivery_id, MAX(created_at) MaxDate + FROM msg_delivery_events + GROUP BY msg_delivery_id +) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id +JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id + AND mde.created_at = MDEMaxDates.MaxDate +WHERE ci.user_id = ? +ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql new file mode 100644 index 0000000000..eb6426ba96 --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql @@ -0,0 +1,32 @@ +SELECT + c.contact_id, + cp.display_name, + cp.full_name, + cp.properties, + ci.chat_item_id, + ci.chat_msg_id, + ci.created_by_msg_id, + ci.item_sent, + ci.item_ts, + ci.item_deleted, + ci.item_text, + ci.item_content, + md.msg_delivery_id, + md.chat_ts, + md.agent_msg_meta, + mde.delivery_status, + mde.created_at +FROM contacts c +JOIN contact_profiles cp ON cp.contact_profile_id == c.contact_profile_id +LEFT JOIN chat_items ci ON ci.contact_id == c.contact_id +JOIN messages m ON m.message_id == ci.created_by_msg_id +JOIN msg_deliveries md ON md.message_id = m.message_id +JOIN ( + SELECT msg_delivery_id, MAX(created_at) MaxDate + FROM msg_delivery_events + GROUP BY msg_delivery_id +) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id +JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id + AND mde.created_at = MDEMaxDates.MaxDate +WHERE c.user_id = ? AND c.contact_id = ? +ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql new file mode 100644 index 0000000000..5e35a9b095 --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql @@ -0,0 +1,38 @@ +SELECT + g.group_id, + gp.display_name, + gp.full_name, + gp.properties, + gm.group_member_id, + cp.display_name, + cp.full_name, + cp.properties, + ci.chat_item_id, + ci.chat_msg_id, + ci.created_by_msg_id, + ci.item_sent, + ci.item_ts, + ci.item_deleted, + ci.item_text, + ci.item_content, + md.msg_delivery_id, + md.chat_ts, + md.agent_msg_meta, + mde.delivery_status, + mde.created_at +FROM groups g +JOIN group_profiles gp ON gp.group_profile_id == g.group_profile_id +LEFT JOIN chat_items ci ON ci.group_id == g.group_id +LEFT JOIN group_members ON gm.group_member_id == ci.group_member_id +JOIN contact_profiles cp ON cp.contact_profile_id == gm.contact_profile_id +JOIN messages m ON m.message_id == ci.created_by_msg_id +JOIN msg_deliveries md ON md.message_id = m.message_id +JOIN ( + SELECT msg_delivery_id, MAX(created_at) MaxDate + FROM msg_delivery_events + GROUP BY msg_delivery_id +) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id +JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id + AND mde.created_at = MDEMaxDates.MaxDate +WHERE g.user_id = ? AND g.group_id = ? +ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2873da060f..fe667fac7d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -26,28 +26,22 @@ import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol 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 Connection (Maybe Contact) + | RcvGroupMsgConnection Connection GroupInfo GroupMember + | SndFileConnection Connection SndFileTransfer + | RcvFileConnection Connection RcvFileTransfer + | UserContactConnection Connection UserContact + deriving (Eq, Show) -deriving instance Eq (ChatDirection p) - -deriving instance Show (ChatDirection p) - -fromConnection :: ChatDirection 'Agent -> Connection +fromConnection :: ConnectionEntity -> Connection fromConnection = \case - ReceivedDirectMessage conn _ -> conn - ReceivedGroupMessage conn _ _ -> conn + RcvDirectMsgConnection conn _ -> conn + RcvGroupMsgConnection conn _ _ -> conn SndFileConnection conn _ -> conn RcvFileConnection conn _ -> conn UserContactConnection conn _ -> conn diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index d53b0af769..2c39f09143 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -42,11 +43,13 @@ module Simplex.Chat.Store getPendingSndChunks, getPendingConnections, getContactConnections, - getConnectionChatDirection, + getConnectionEntity, updateConnectionStatus, createNewGroup, createGroupInvitation, getGroup, + getGroupInfo, + getGroupMembers, deleteGroup, getUserGroups, getUserGroupDetails, @@ -88,6 +91,7 @@ module Simplex.Chat.Store createRcvFileChunk, updatedRcvFileChunkStored, deleteRcvFileChunks, + updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, createNewMessage, @@ -98,6 +102,7 @@ module Simplex.Chat.Store createPendingGroupMessage, getPendingGroupMessages, deletePendingGroupMessage, + createNewChatItem, ) where @@ -115,7 +120,7 @@ import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy) -import Data.Maybe (listToMaybe) +import Data.Maybe (fromJust, isJust, listToMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) @@ -125,10 +130,11 @@ import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Migrations.M20220101_initial import Simplex.Chat.Migrations.M20220122_pending_group_messages +import Simplex.Chat.Migrations.M20220125_chat_items 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.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, (<$$>)) @@ -138,7 +144,8 @@ import UnliftIO.STM schemaMigrations :: [(String, Query)] schemaMigrations = [ ("20220101_initial", m20220101_initial), - ("20220122_pending_group_messages", m20220122_pending_group_messages) + ("20220122_pending_group_messages", m20220122_pending_group_messages), + ("20220125_chat_items", m20220125_chat_items) ] -- | The list of migrations in ascending order by date @@ -771,19 +778,19 @@ mergeContactRecords st userId Contact {contactId = toContactId} Contact {contact 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 + then pure $ RcvDirectMsgConnection c Nothing else throwError $ SEInternal $ "connection " <> bshow 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 @@ -820,27 +827,37 @@ getConnectionChatDirection st User {userId, userContactId} agentConnId = 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) + getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ db groupMemberId c = ExceptT $ do - toGroupAndMember c - <$> DB.query + firstRow (toGroupAndMember c) (SEInternal "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, + -- 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 + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, + -- user 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 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] = + (groupMemberId, userId, userContactId) + toGroupAndMember :: Connection -> (Int64, GroupName, GroupName, Text) :. GroupMemberRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember c ((groupId, localDisplayName, displayName, fullName) :. memberRow :. userMemberRow) = let member = toGroupMember userContactId memberRow - in Right (groupName, (member :: GroupMember) {activeConn = Just c}) - toGroupAndMember _ _ = Left $ SEInternal "referenced group member not found" + membership = toGroupMember userContactId userMemberRow + in ( GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership}, + (member :: GroupMember) {activeConn = Just c} + ) getConnSndFileTransfer_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer getConnSndFileTransfer_ db fileId Connection {connId} = ExceptT $ @@ -859,7 +876,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 @@ -884,7 +901,7 @@ updateConnectionStatus st Connection {connId} connStatus = DB.execute db "UPDATE connections SET conn_status = ? WHERE connection_id = ?" (connStatus, 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 @@ -896,23 +913,23 @@ createNewGroup st gVar user groupProfile = 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} + pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership} -- | 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 + StoreMonad m => SQLiteStore -> User -> Contact -> GroupInvitation -> m GroupInfo createGroupInvitation st user@User {userId} contact GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = liftIOEither . withTransaction st $ \db -> do getGroupInvitationLdn_ 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 localDisplayName -> getGroupInfo_ db user localDisplayName where getGroupInvitationLdn_ :: DB.Connection -> IO (Maybe GroupName) getGroupInvitationLdn_ 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) + createGroupInvitation_ :: DB.Connection -> IO (Either StoreError GroupInfo) createGroupInvitation_ db = do let GroupProfile {displayName, fullName} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> do @@ -920,73 +937,24 @@ createGroupInvitation st user@User {userId} contact GroupInvitation {fromMember, 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) groupId <- insertedRowId db - member <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown + _ <- 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} + pure $ GroupInfo {groupId, localDisplayName, groupProfile, membership} -- 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 + liftIOEither . withTransaction st $ \db -> runExceptT $ getGroup_ db user localDisplayName -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_ :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO Group +getGroup_ db user gName = do + gInfo <- ExceptT $ getGroupInfo_ db user gName + 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) @@ -998,36 +966,97 @@ 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 + rights <$> mapM (runExceptT . getGroup_ db user) groupNames -getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> UserId -> m [GroupInfo] -getUserGroupDetails st userId = +getUserGroupDetails :: MonadUnliftIO m => SQLiteStore -> User -> m [GroupInfo] +getUserGroupDetails st User {userId, userContactId} = liftIO . withTransaction st $ \db -> - map groupInfo + map (toGroupInfo userContactId) <$> DB.query db [sql| - SELECT g.group_id, g.local_display_name, p.display_name, p.full_name, m.member_status + SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, + 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 p USING (group_profile_id) + JOIN group_profiles gp USING (group_profile_id) JOIN group_members m USING (group_id) - WHERE g.user_id = ? AND m.member_category = 'user' + JOIN contact_profiles mp USING (contact_profile_id) + WHERE g.user_id = ? AND m.contact_id = ? |] - (Only userId) - where - groupInfo (groupId, localDisplayName, displayName, fullName, userMemberStatus) = - GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, userMemberStatus} + (userId, userContactId) +getGroupInfo :: StoreMonad m => SQLiteStore -> User -> GroupName -> m GroupInfo +getGroupInfo st user gName = liftIOEither . withTransaction st $ \db -> getGroupInfo_ db user gName + +getGroupInfo_ :: DB.Connection -> User -> GroupName -> IO (Either StoreError GroupInfo) +getGroupInfo_ db User {userId, userContactId} gName = + firstRow (toGroupInfo userContactId) (SEGroupNotFound gName) $ + DB.query + db + [sql| + SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, + 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.local_display_name = ? AND g.user_id = ? AND m.contact_id = ? + |] + (gName, userId, userContactId) + +toGroupInfo :: Int64 -> (Int64, GroupName, GroupName, Text) :. GroupMemberRow -> GroupInfo +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName) :. memberRow) = + let membership = toGroupMember userContactId memberRow + in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} + +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 + 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 != ?) + |] + (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 + Group groupInfo@GroupInfo {membership} members <- getGroup_ db user localDisplayName 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 (SEGroupNotFound 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 @@ -1076,8 +1105,8 @@ updateGroupMemberStatus st userId GroupMember {groupMemberId} memStatus = ] -- | 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) @@ -1092,13 +1121,13 @@ createNewGroupMember st user@User {userId} group memInfo@(MemberInfo _ _ Profile memContactId = Nothing, memProfileId } - createNewMember_ db user group newMember + createNewMember_ db user gInfo newMember -createNewMember_ :: DB.Connection -> User -> Group -> NewGroupMember -> IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> IO GroupMember createNewMember_ db User {userId, userContactId} - Group {groupId} + GroupInfo {groupId} NewGroupMember { memInfo = MemberInfo memberId memberRole memberProfile, memCategory = memberCategory, @@ -1129,8 +1158,8 @@ 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 [] @@ -1218,8 +1247,8 @@ 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 @@ -1235,7 +1264,7 @@ createIntroReMember st user@User {userId} group@Group {groupId} _host@GroupMembe memContactId = Just contactId, memProfileId } - member <- createNewMember_ db user group newMember + member <- createNewMember_ db user gInfo newMember conn <- createMemberConnection_ db userId (groupMemberId member) groupAgentConnId memberContactId cLevel pure (member :: GroupMember) {activeConn = Just conn} @@ -1318,7 +1347,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ":sent_inv_queue_info" := connRequest ] -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 @@ -1326,28 +1355,40 @@ 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, + -- 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, - 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 + 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, + -- user 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 group_members m 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] = + toGroupAndMember :: [(Int64, GroupName, GroupName, Text) :. GroupMemberRow :. MaybeConnectionRow :. GroupMemberRow] -> Maybe (GroupInfo, GroupMember) + toGroupAndMember [(groupId, localDisplayName, displayName, fullName) :. memberRow :. connRow :. userMemberRow] = let member = toGroupMember userContactId memberRow - in Just (groupName, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + membership = toGroupMember userContactId userMemberRow + in Just + ( GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership}, + (member :: GroupMember) {activeConn = toMaybeConnection connRow} + ) toGroupAndMember _ = Nothing getViaGroupContact :: MonadUnliftIO m => SQLiteStore -> User -> GroupMember -> m (Maybe Contact) @@ -1382,17 +1423,17 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = 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} aConnId 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) fileId <- insertedRowId db - Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId + Connection {connId} <- createSndFileConnection_ db userId fileId aConnId let fileStatus = FSNew DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id) VALUES (?, ?, ?)" (fileId, fileStatus, connId) - pure SndFileTransfer {..} + pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId aConnId} -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) @@ -1496,7 +1537,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} @@ -1577,6 +1618,11 @@ 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 -> + DB.execute db "UPDATE files SET chat_item_id = ? WHERE file_id = ?" (ciId, fileId) + getFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m FileTransfer getFileTransfer st userId fileId = liftIOEither . withTransaction st $ \db -> @@ -1627,15 +1673,15 @@ 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 Message +createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> m MessageId createNewMessage st newMsg = liftIO . withTransaction st $ \db -> createNewMessage_ db newMsg @@ -1646,13 +1692,13 @@ createSndMsgDelivery st sndMsgDelivery messageId = msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent -createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m Message +createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m MessageId createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery = liftIO . withTransaction st $ \db -> do - msg@Message {msgId} <- createNewMessage_ db newMsg - msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery msgId + messageId <- createNewMessage_ db newMsg + msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery messageId createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent - pure msg + pure messageId createSndMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> m () createSndMsgDeliveryEvent st connId agentMsgId sndMsgDeliveryStatus = @@ -1666,18 +1712,17 @@ createRcvMsgDeliveryEvent st connId agentMsgId rcvMsgDeliveryStatus = msgDeliveryId <- ExceptT $ getMsgDeliveryId_ db connId agentMsgId liftIO $ createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus -createNewMessage_ :: DB.Connection -> NewMessage -> IO Message -createNewMessage_ db NewMessage {direction, cmEventTag, chatTs, msgBody} = do +createNewMessage_ :: DB.Connection -> NewMessage -> IO MessageId +createNewMessage_ db NewMessage {direction, cmEventTag, msgBody} = do createdAt <- getCurrentTime DB.execute db [sql| INSERT INTO messages - (msg_sent, chat_msg_event, chat_ts, msg_body, created_at) VALUES (?,?,?,?,?); + (msg_sent, chat_msg_event, msg_body, created_at) VALUES (?,?,?,?); |] - (direction, cmEventTag, chatTs, msgBody, createdAt) - msgId <- insertedRowId db - pure Message {msgId, direction, cmEventTag, chatTs, msgBody, createdAt} + (direction, cmEventTag, msgBody, createdAt) + insertedRowId db createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> IO Int64 createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId = do @@ -1767,6 +1812,72 @@ 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 => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId +createNewChatItem st userId chatDirection NewChatItem {createdByMsgId_, itemSent, itemTs, itemContent, itemText, 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, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, contactId_, groupId_, groupMemberId_) + :. (createdByMsgId_, itemSent, itemTs, itemContent, itemText, createdAt, createdAt) + ) + ciId <- insertedRowId db + when (isJust createdByMsgId_) $ + DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id) VALUES (?,?)" (ciId, fromJust createdByMsgId_) + pure ciId + where + ids :: (Maybe Int64, Maybe Int64, Maybe Int64) + ids = case chatDirection of + CDDirect Contact {contactId} -> (Just contactId, Nothing, Nothing) + CDSndGroup GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) + CDRcvGroup GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId) + +-- getDirectChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList +-- getDirectChatItemList st userId contactId = +-- liftIO . withTransaction st $ \db -> +-- DB.query +-- db +-- [sql| +-- ... +-- |] +-- (userId, contactId) + +-- getGroupChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList +-- getGroupChatItemList st userId groupId = +-- liftIO . withTransaction st $ \db -> +-- DB.query +-- db +-- [sql| +-- ... +-- |] +-- (userId, groupId) + +-- getChatInfoList :: MonadUnliftIO m => SQLiteStore -> UserId -> m [ChatInfo] +-- getChatInfoList st userId = +-- liftIO . withTransaction st $ \db -> +-- DB.query +-- db +-- [sql| +-- ... +-- |] +-- (Only userId) + +-- getChatItemsMixed :: MonadUnliftIO m => SQLiteStore -> UserId -> m [AnyChatItem] +-- getChatItemsMixed st userId = +-- liftIO . withTransaction st $ \db -> +-- DB.query +-- db +-- [sql| +-- ... +-- |] +-- (Only userId) + -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 79bd105c52..1bfcdfe32b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -13,6 +13,7 @@ module Simplex.Chat.Types where 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 Data.ByteString.Char8 (ByteString) @@ -56,7 +57,7 @@ data User = User } deriving (Show, Generic, FromJSON) -instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} type UserId = Int64 @@ -95,22 +96,19 @@ type ContactName = Text type GroupName = Text -data Group = Group - { groupId :: Int64, - localDisplayName :: GroupName, - groupProfile :: GroupProfile, - members :: [GroupMember], - membership :: GroupMember - } +data Group = Group GroupInfo [GroupMember] deriving (Eq, Show) data GroupInfo = GroupInfo { groupId :: Int64, localDisplayName :: GroupName, groupProfile :: GroupProfile, - userMemberStatus :: GroupMemberStatus + membership :: GroupMember } - deriving (Show) + deriving (Eq, Show) + +groupName :: GroupInfo -> GroupName +groupName GroupInfo {localDisplayName = g} = g data Profile = Profile { displayName :: ContactName, @@ -118,7 +116,7 @@ data Profile = Profile } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data GroupProfile = GroupProfile { displayName :: GroupName, @@ -126,7 +124,7 @@ data GroupProfile = GroupProfile } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, @@ -136,7 +134,7 @@ data GroupInvitation = GroupInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data MemberIdRole = MemberIdRole { memberId :: MemberId, @@ -144,7 +142,7 @@ data MemberIdRole = MemberIdRole } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, @@ -152,7 +150,7 @@ data IntroInvitation = IntroInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON IntroInvitation where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON IntroInvitation where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data MemberInfo = MemberInfo { memberId :: MemberId, @@ -161,7 +159,7 @@ data MemberInfo = MemberInfo } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile} = @@ -169,9 +167,8 @@ memberInfo GroupMember {memberId, memberRole, memberProfile} = data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, - userMember :: GroupMember, connRequest :: ConnReqInvitation, - groupProfile :: GroupProfile + groupInfo :: GroupInfo } deriving (Eq, Show) @@ -418,7 +415,7 @@ data SndFileTransfer = SndFileTransfer chunkSize :: Integer, recipientDisplayName :: ContactName, connId :: Int64, - agentConnId :: ConnId, + agentConnId :: AgentConnId, fileStatus :: FileStatus } deriving (Eq, Show) @@ -456,7 +453,9 @@ data RcvFileTransfer = RcvFileTransfer senderDisplayName :: ContactName, chunkSize :: Integer } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data RcvFileStatus = RFSNew @@ -466,13 +465,65 @@ data RcvFileStatus | RFSCancelled RcvFileInfo deriving (Eq, Show) +instance FromJSON RcvFileStatus where + parseJSON = J.withObject "RcvFileStatus" $ \v -> do + let rfs mk = mk <$> v .: "fileInfo" + v .: "status" >>= \case + ("new" :: Text) -> pure RFSNew + "accepted" -> rfs RFSAccepted + "connected" -> rfs RFSConnected + "complete" -> rfs RFSComplete + "cancelled" -> rfs RFSCancelled + _ -> fail "bad RcvFileStatus" + +instance ToJSON RcvFileStatus where + toJSON s = J.object $ ["status" .= rfsTag s, "fileInfo" .= rfsInfo s] + toEncoding s = J.pairs $ ("status" .= rfsTag s <> "fileInfo" .= rfsInfo s) + +rfsTag :: RcvFileStatus -> Text +rfsTag = \case + RFSNew -> "new" + RFSAccepted _ -> "accepted" + RFSConnected _ -> "connected" + RFSComplete _ -> "complete" + RFSCancelled _ -> "cancelled" + +rfsInfo :: RcvFileStatus -> Maybe RcvFileInfo +rfsInfo = \case + RFSNew -> Nothing + RFSAccepted info -> Just info + RFSConnected info -> Just info + RFSComplete info -> Just info + RFSCancelled info -> Just info + 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 {J.omitNothingFields = True} + +newtype AgentConnId = AgentConnId ConnId deriving (Eq, Show) +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 + data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer deriving (Show) @@ -482,6 +533,13 @@ instance FromField FileStatus where fromField = fromTextField_ fileStatusT instance ToField FileStatus where toField = toField . serializeFileStatus +instance FromJSON FileStatus where + parseJSON = J.withText "FileStatus" $ maybe (fail "bad FileStatus") pure . fileStatusT + +instance ToJSON FileStatus where + toJSON = J.String . serializeFileStatus + toEncoding = JE.text . serializeFileStatus + fileStatusT :: Text -> Maybe FileStatus fileStatusT = \case "new" -> Just FSNew @@ -634,8 +692,6 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -type MessageId = Int64 - data Notification = Notification {title :: Text, text :: Text} type JSONString = String diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 3b78e9bcb7..8ae4758537 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -32,14 +33,7 @@ serializeChatResponse = unlines . map unStyle . responseToView "" responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case - CRSentMessage c mc meta -> viewSentMessage (ttyToContact c) mc meta - CRSentGroupMessage g mc meta -> viewSentMessage (ttyToGroup g) mc meta - CRSentFileInvitation c fId fPath meta -> viewSentFileInvitation (ttyToContact c) fId fPath meta - CRSentGroupFileInvitation g fId fPath meta -> viewSentFileInvitation (ttyToGroup g) fId fPath meta - CRReceivedMessage c meta mc mOk -> viewReceivedMessage (ttyFromContact c) meta mc mOk - CRReceivedGroupMessage g c meta mc mOk -> viewReceivedMessage (ttyFromGroup g c) meta mc mOk - CRReceivedFileInvitation c meta ft mOk -> viewReceivedFileInvitation (ttyFromContact c) meta ft mOk - CRReceivedGroupFileInvitation g c meta ft mOk -> viewReceivedFileInvitation (ttyFromGroup g c) meta ft mOk + CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRCommandAccepted _ -> r [] CRChatHelp section -> case section of HSMain -> r chatHelpInfo @@ -54,7 +48,7 @@ responseToView cmd = \case 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] + 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"] @@ -67,10 +61,10 @@ responseToView cmd = \case CRAcceptingContactRequest c -> r' [ttyContact c <> ": accepting contact request..."] CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted - CRUserAcceptedGroupSent _gn -> 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"] + 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 @@ -89,24 +83,24 @@ responseToView cmd = \case CRSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} -> [ttyContact c <> " cancelled receiving " <> sndFile ft] 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 Group {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} -> + 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"] + 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 gn m -> [ttyGroup gn <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> gn) <> " to delete the local copy of the group"] - CRMemberSubError gn c e -> [ttyGroup gn <> " member " <> ttyContact c <> " error: " <> sShow e] + 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] @@ -121,6 +115,33 @@ responseToView cmd = \case -- this function should be `id` in case of asynchronous command responses r' = r +viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] +viewChatItem chat item = case (chat, item) of + (DirectChat c, DirectChatItem ciMeta content) -> case ciMeta of + CISndMeta meta -> case content of + CIMsgContent mc -> viewSentMessage to mc meta + CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta + CIRcvMeta meta mOk -> case content of + CIMsgContent mc -> viewReceivedMessage from meta mc mOk + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft mOk + where + to = ttyToContact' c + from = ttyFromContact' c + (GroupChat g, SndGroupChatItem (CISndMeta meta) content) -> case content of + CIMsgContent mc -> viewSentMessage to mc meta + CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta + where + to = ttyToGroup g + (GroupChat g, RcvGroupChatItem c (CIRcvMeta meta mOk) content) -> case content of + CIMsgContent mc -> viewReceivedMessage from meta mc mOk + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft mOk + where + from = ttyFromGroup' g c + where + ttyToContact' Contact {localDisplayName = c} = ttyToContact c + ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c + ttyFromGroup' g GroupMember {localDisplayName = c} = ttyFromGroup g c + viewInvalidConnReq :: [StyledString] viewInvalidConnReq = [ "", @@ -167,26 +188,26 @@ viewReceivedContactRequest c Profile {fullName} = "to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)" ] -viewGroupCreated :: Group -> [StyledString] -viewGroupCreated g@Group {localDisplayName} = +viewGroupCreated :: GroupInfo -> [StyledString] +viewGroupCreated g@GroupInfo {localDisplayName} = [ "group " <> ttyFullGroup g <> " is created", "use " <> highlight ("/a " <> localDisplayName <> " ") <> " to add members" ] -viewCannotResendInvitation :: GroupName -> ContactName -> [StyledString] -viewCannotResendInvitation 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) ] -viewReceivedGroupInvitation :: Group -> ContactName -> GroupMemberRole -> [StyledString] -viewReceivedGroupInvitation 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" ] -groupPreserved :: GroupName -> [StyledString] -groupPreserved g = ["use " <> highlight ("/d #" <> g) <> " to delete the group"] +groupPreserved :: GroupInfo -> [StyledString] +groupPreserved g = ["use " <> highlight ("/d #" <> groupName g) <> " to delete the group"] connectedMember :: GroupMember -> StyledString connectedMember m = case memberCategory m of @@ -195,7 +216,7 @@ connectedMember m = case memberCategory m of _ -> "member " <> ttyMember m -- these case is not used viewGroupMembers :: Group -> [StyledString] -viewGroupMembers Group {membership, members} = map groupMember . filter (not . removedOrLeft) $ membership : members +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 @@ -219,8 +240,8 @@ viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g GroupName) - groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, userMemberStatus} = - case userMemberStatus of + groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} = + case memberStatus membership of GSMemInvited -> groupInvitation ldn fullName _ -> ttyGroup ldn <> optFullName ldn fullName @@ -268,17 +289,17 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> ChatMsgMeta -> MsgContent -> MsgIntegrity -> [StyledString] +viewReceivedMessage :: StyledString -> CIMetaProps -> MsgContent -> MsgIntegrity -> [StyledString] viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) -receivedWithTime_ :: StyledString -> ChatMsgMeta -> [StyledString] -> MsgIntegrity -> [StyledString] -receivedWithTime_ from ChatMsgMeta {localChatTs, createdAt} styledMsg mOk = do +receivedWithTime_ :: StyledString -> CIMetaProps -> [StyledString] -> MsgIntegrity -> [StyledString] +receivedWithTime_ from CIMetaProps {localItemTs, createdAt} styledMsg mOk = do prependFirst (formattedTime <> " " <> from) styledMsg ++ showIntegrity mOk where formattedTime :: StyledString formattedTime = - let localTime = zonedTimeToLocalTime localChatTs - tz = zonedTimeZone localChatTs + let localTime = zonedTimeToLocalTime localItemTs + tz = zonedTimeZone localItemTs format = if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz createdAt)) && (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime)) @@ -297,15 +318,15 @@ receivedWithTime_ from ChatMsgMeta {localChatTs, createdAt} styledMsg mOk = do msgError :: String -> [StyledString] msgError s = [styled (Colored Red) s] -viewSentMessage :: StyledString -> MsgContent -> ChatMsgMeta -> [StyledString] +viewSentMessage :: StyledString -> MsgContent -> CIMetaProps -> [StyledString] viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent -viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> ChatMsgMeta -> [StyledString] +viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMetaProps -> [StyledString] viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath -sentWithTime_ :: [StyledString] -> ChatMsgMeta -> [StyledString] -sentWithTime_ styledMsg ChatMsgMeta {localChatTs} = - prependFirst (ttyMsgTime localChatTs <> " ") styledMsg +sentWithTime_ :: [StyledString] -> CIMetaProps -> [StyledString] +sentWithTime_ styledMsg CIMetaProps {localItemTs} = + prependFirst (ttyMsgTime localItemTs <> " ") styledMsg ttyMsgTime :: ZonedTime -> StyledString ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M" @@ -342,7 +363,7 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName -viewReceivedFileInvitation :: StyledString -> ChatMsgMeta -> RcvFileTransfer -> MsgIntegrity -> [StyledString] +viewReceivedFileInvitation :: StyledString -> CIMetaProps -> RcvFileTransfer -> MsgIntegrity -> [StyledString] viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] @@ -425,7 +446,7 @@ viewChatError = \case 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"] @@ -466,6 +487,9 @@ viewChatError = \case 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 @@ -489,20 +513,23 @@ ttyFromContact c = styled (Colored Yellow) $ c <> "> " ttyGroup :: GroupName -> StyledString ttyGroup g = styled (Colored Blue) $ "#" <> g +ttyGroup' :: GroupInfo -> StyledString +ttyGroup' = ttyGroup . groupName + ttyGroups :: [GroupName] -> StyledString ttyGroups [] = "" ttyGroups [g] = ttyGroup g ttyGroups (g : gs) = ttyGroup g <> ", " <> ttyGroups gs -ttyFullGroup :: Group -> StyledString -ttyFullGroup Group {localDisplayName, groupProfile = GroupProfile {fullName}} = - ttyGroup localDisplayName <> optFullName localDisplayName fullName +ttyFullGroup :: GroupInfo -> StyledString +ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName}} = + ttyGroup g <> optFullName g fullName -ttyFromGroup :: GroupName -> ContactName -> StyledString -ttyFromGroup g c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> " +ttyFromGroup :: GroupInfo -> ContactName -> StyledString +ttyFromGroup GroupInfo {localDisplayName = g} c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> " -ttyToGroup :: GroupName -> StyledString -ttyToGroup g = styled (Colored Cyan) $ "#" <> g <> " " +ttyToGroup :: GroupInfo -> StyledString +ttyToGroup GroupInfo {localDisplayName = g} = styled (Colored Cyan) $ "#" <> g <> " " ttyFilePath :: FilePath -> StyledString ttyFilePath = plain From ecb5b0fdeb09a5e4695d78e1c8ba845c566d8945 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Wed, 26 Jan 2022 21:19:46 +0400 Subject: [PATCH 13/82] add getChatPreviews to Store (#225) --- src/Simplex/Chat/Store.hs | 82 ++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 2c39f09143..b7091e0d5b 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -103,6 +103,7 @@ module Simplex.Chat.Store getPendingGroupMessages, deletePendingGroupMessage, createNewChatItem, + getChatPreviews, ) where @@ -368,6 +369,14 @@ updateContact_ db userId contactId displayName newName = do DB.execute db "UPDATE group_members SET local_display_name = ? WHERE user_id = ? AND contact_id = ?" (newName, 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) :. ConnectionRow + +toContact' :: ContactRow -> Contact +toContact' ((contactId, localDisplayName, viaGroup, displayName, fullName) :. connRow) = + let profile = Profile {displayName, fullName} + activeConn = toConnection connRow + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup} + -- 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 @@ -1838,6 +1847,69 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId_, itemSent CDSndGroup GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) CDRcvGroup GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId) +getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChatPreview] +getChatPreviews st user = + liftIO . withTransaction st $ \db -> do + directChatPreviews <- getDirectChatPreviews_ db user + groupChatPreviews <- getGroupChatPreviews_ db user + pure $ directChatPreviews <> groupChatPreviews + +getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] +getDirectChatPreviews_ db User {userId} = + map toDirectChatPreview + <$> DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, + -- Contact {profile} + cp.display_name, cp.full_name, + -- Contact {activeConn} + 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 + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? + |] + (Only userId) + where + toDirectChatPreview :: ContactRow -> AChatPreview + toDirectChatPreview contactRow = + let contact = toContact' contactRow + in AChatPreview SCTDirect (DirectChat contact) Nothing + +getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] +getGroupChatPreviews_ db User {userId, userContactId} = + map toGroupChatPreview + <$> DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, + -- GroupInfo {groupProfile} + gp.display_name, gp.full_name, + -- 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 groups g + JOIN group_profiles gp ON gp.group_profile_id == g.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 g.user_id = ? + |] + (Only userId) + where + toGroupChatPreview :: (Int64, GroupName, GroupName, Text) :. GroupMemberRow -> AChatPreview + toGroupChatPreview ((groupId, localDisplayName, displayName, fullName) :. userMemberRow) = + let membership = toGroupMember userContactId userMemberRow + groupInfo = GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} + in AChatPreview SCTGroup (GroupChat groupInfo) Nothing + -- getDirectChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList -- getDirectChatItemList st userId contactId = -- liftIO . withTransaction st $ \db -> @@ -1858,16 +1930,6 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId_, itemSent -- |] -- (userId, groupId) --- getChatInfoList :: MonadUnliftIO m => SQLiteStore -> UserId -> m [ChatInfo] --- getChatInfoList st userId = --- liftIO . withTransaction st $ \db -> --- DB.query --- db --- [sql| --- ... --- |] --- (Only userId) - -- getChatItemsMixed :: MonadUnliftIO m => SQLiteStore -> UserId -> m [AnyChatItem] -- getChatItemsMixed st userId = -- liftIO . withTransaction st $ \db -> From 0ba4598ca227c739552a806ccd95bc6c7bb809bd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 26 Jan 2022 21:20:08 +0000 Subject: [PATCH 14/82] JSON encoding for ChatResponse and all other types used in mobile API (#226) * JSON encoding for ChatResponse and all other types used in mobile API * omit null corrId in response, refactor * more JSON field names --- cabal.project | 2 +- sha256map.nix | 2 +- src/Simplex/Chat.hs | 85 ++++---- src/Simplex/Chat/Controller.hs | 183 +++++++++------- src/Simplex/Chat/Messages.hs | 113 ++++++++-- src/Simplex/Chat/Mobile.hs | 44 ++-- src/Simplex/Chat/Protocol.hs | 4 +- src/Simplex/Chat/Store.hs | 59 +++--- src/Simplex/Chat/Types.hs | 376 +++++++++++++++++---------------- src/Simplex/Chat/View.hs | 10 +- stack.yaml | 2 +- tests/ChatTests.hs | 1 + 12 files changed, 482 insertions(+), 399 deletions(-) diff --git a/cabal.project b/cabal.project index 39619484b6..9672e6c7ef 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: b777a4fd93f888d549edf1877583fb7fc0e0196f + tag: 6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b source-repository-package type: git diff --git a/sha256map.nix b/sha256map.nix index 71494a7c5b..7134fe7564 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "git://github.com/simplex-chat/simplexmq.git"."b777a4fd93f888d549edf1877583fb7fc0e0196f" = "0cnbc9swdzb29j3pv4z64w26sq8dsp4ixnnv5bbf5k6dz9bwl9zm"; + "git://github.com/simplex-chat/simplexmq.git"."6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b" = "0cnbc9swdzb29j3pv4z64w26sq8dsp4ixnnv5bbf5k6dz9bwl9zm"; "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cab1767d05..8a25a5b584 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -52,7 +52,7 @@ 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 (CorrId (..), MsgBody) +import Simplex.Messaging.Protocol (MsgBody) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Util (tryError) import System.Exit (exitFailure, exitSuccess) @@ -120,7 +120,7 @@ execChatCommand s = case parseAll chatCommandP . B.dropWhileEnd isSpace . encode toView :: ChatMonad m => ChatResponse -> m () toView event = do q <- asks outputQ - atomically $ writeTBQueue q (CorrId "", event) + atomically $ writeTBQueue q (Nothing, event) processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResponse processChatCommand user@User {userId, profile} = \case @@ -136,7 +136,7 @@ processChatCommand user@User {userId, profile} = \case Connect (Just (ACR SCMContact cReq)) -> procCmd $ do connect cReq $ XContact profile Nothing pure CRSentInvitation - Connect Nothing -> chatError CEInvalidConnReq + Connect Nothing -> throwChatError CEInvalidConnReq ConnectAdmin -> procCmd $ do connect adminContactReq $ XContact profile Nothing pure CRSentInvitation @@ -145,12 +145,12 @@ processChatCommand user@User {userId, profile} = \case [] -> do conns <- withStore $ \st -> getContactConnections st userId cName procCmd $ do - withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> - deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () + withAgent $ \a -> forM_ conns $ \conn -> + deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId cName unsetActive $ ActiveC cName pure $ CRContactDeleted cName - gs -> chatError $ CEContactGroups cName gs + gs -> throwChatError $ CEContactGroups cName gs ListContacts -> CRContactsList <$> withStore (`getUserContacts` user) CreateMyAddress -> procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) @@ -159,8 +159,8 @@ processChatCommand user@User {userId, profile} = \case DeleteMyAddress -> do conns <- withStore $ \st -> getUserContactLinkConnections st userId procCmd $ do - withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> - deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () + withAgent $ \a -> forM_ conns $ \conn -> + deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteUserContactLink st userId pure CRUserContactLinkDeleted ShowMyAddress -> CRUserContactLink <$> withStore (`getUserContactLink` userId) @@ -191,9 +191,9 @@ processChatCommand user@User {userId, profile} = \case (group, contact) <- withStore $ \st -> (,) <$> getGroup st user gName <*> getContact 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 gInfo) - 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 void . sendDirectMessage (contactConn contact) $ XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile @@ -209,8 +209,8 @@ processChatCommand user@User {userId, profile} = \case | memberStatus == GSMemInvited -> withStore (\st -> getMemberInvitation st user groupMemberId) >>= \case Just cReq -> sendInvitation memberId cReq - Nothing -> chatError $ CEGroupCantResendInvitation gInfo cName - | otherwise -> chatError $ CEGroupDuplicateMember cName + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName + | otherwise -> throwChatError $ CEGroupDuplicateMember cName JoinGroup gName -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \st -> getGroupInvitation st user gName procCmd $ do @@ -220,14 +220,14 @@ processChatCommand user@User {userId, profile} = \case updateGroupMemberStatus st userId fromMember GSMemAccepted updateGroupMemberStatus st userId (membership g) GSMemAccepted pure $ CRUserAcceptedGroupSent g - MemberRole _gName _cName _mRole -> chatError $ CECommandError "unsupported" + MemberRole _gName _cName _mRole -> throwChatError $ CECommandError "unsupported" RemoveMember gName cName -> do Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup 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 (userRole < GRAdmin || userRole < mRole) $ throwChatError CEGroupUserRole procCmd $ do when (mStatus /= GSMemInvited) . void . sendGroupMessage members $ XGrpMemDel mId deleteMemberConnection m @@ -246,7 +246,7 @@ processChatCommand user@User {userId, profile} = \case canDelete = memberRole (membership :: GroupMember) == GROwner || (s == GSMemRemoved || s == GSMemLeft || s == GSMemGroupDeleted || s == GSMemInvited) - unless canDelete $ chatError CEGroupUserRole + unless canDelete $ throwChatError CEGroupUserRole procCmd $ do when (memberActive membership) . void $ sendGroupMessage members XGrpDel mapM_ deleteMemberConnection members @@ -256,7 +256,7 @@ processChatCommand user@User {userId, profile} = \case ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` user) SendGroupMessage gName msg -> do group@(Group gInfo@GroupInfo {membership} _) <- withStore $ \st -> getGroup st user gName - unless (memberActive membership) $ chatError CEGroupMemberUserRemoved + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved let mc = MCText $ safeDecodeUtf8 msg ci <- sendGroupChatItem userId group (XMsgNew mc) (CIMsgContent mc) setActive $ ActiveG gName @@ -275,7 +275,7 @@ processChatCommand user@User {userId, profile} = \case SendGroupFile gName f -> do (fileSize, chSize) <- checkSndFile f Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName - unless (memberActive membership) $ chatError CEGroupMemberUserRemoved + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved let fileName = takeFileName f ms <- forM (filter memberActive members) $ \m -> do (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) @@ -287,12 +287,12 @@ processChatCommand user@User {userId, profile} = \case setActive $ ActiveG gName -- this is a hack as we have multiple direct messages instead of one per group let ciContent = CISndFileInvitation fileId f - ciMeta@CIMetaProps{itemId} <- saveChatItem userId (CDSndGroup gInfo) Nothing ciContent + ciMeta@CIMetaProps {itemId} <- saveChatItem userId (CDSndGroup gInfo) Nothing ciContent withStore $ \st -> updateFileTransferChatItemId st fileId itemId pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ SndGroupChatItem (CISndMeta ciMeta) ciContent ReceiveFile fileId filePath_ -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId - unless (fileStatus == RFSNew) . chatError $ CEFileAlreadyReceiving fileName + unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName procCmd $ do tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case Right agentConnId -> do @@ -336,8 +336,8 @@ processChatCommand user@User {userId, profile} = \case -- corrId <- liftIO $ CorrId <$> randomBytes gVar 8 -- q <- asks outputQ -- void . forkIO $ atomically . writeTBQueue q =<< - -- (corrId,) <$> (a `catchError` (pure . CRChatError)) - -- pure $ CRCommandAccepted corrId + -- (Just corrId,) <$> (a `catchError` (pure . CRChatError)) + -- pure $ CRCmdAccepted corrId -- a corrId connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () connect cReq msg = do @@ -349,7 +349,7 @@ processChatCommand user@User {userId, profile} = \case 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 @@ -364,11 +364,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 @@ -454,8 +454,7 @@ subscribeUserConnections = void . runExceptT $ do 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 @@ -685,7 +684,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do cancelSndFileTransfer ft case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ toView $ CRSndFileRcvCancelled ft - _ -> chatError $ CEFileSend fileId err + _ -> throwChatError $ CEFileSend fileId err MSG meta _ -> withAckMessage agentConnId meta $ pure () -- TODO print errors @@ -773,7 +772,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do RFSCancelled _ -> pure () _ -> do cancelRcvFileTransfer ft - chatError $ CEFileRcvChunk err + throwChatError $ CEFileRcvChunk err notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do @@ -841,8 +840,8 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do 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 + 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" @@ -971,7 +970,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do xGrpDel :: GroupInfo -> GroupMember -> m () xGrpDel gInfo m@GroupMember {memberRole} = do - when (memberRole /= GROwner) $ chatError CEGroupUserRole + when (memberRole /= GROwner) $ throwChatError CEGroupUserRole ms <- withStore $ \st -> do members <- getGroupMembers st user gInfo updateGroupMemberStatus st userId (membership gInfo) GSMemGroupDeleted @@ -1003,7 +1002,7 @@ sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do 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 @@ -1036,12 +1035,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 @@ -1088,8 +1087,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 @@ -1115,8 +1114,8 @@ 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 @@ -1146,7 +1145,7 @@ sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do deliverMessage conn msgBody msgId withStore (\st -> deletePendingGroupMessage st groupMemberId msgId) when (cmEventTag == XGrpMemFwd_) $ case introId_ of - Nothing -> chatError $ CEGroupMemberIntroNotFound localDisplayName + Nothing -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m (MessageId, ChatMsgEvent) @@ -1212,8 +1211,8 @@ mkCIMetaProps itemId itemTs createdAt = do pure CIMetaProps {itemId, itemTs, localItemTs, 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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index afff5ae9aa..dff3e67ab8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,5 +1,6 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} @@ -11,10 +12,13 @@ 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.Messages import Simplex.Chat.Store (StoreError) @@ -23,6 +27,7 @@ import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId) import System.IO (Handle) import UnliftIO.STM @@ -54,7 +59,7 @@ data ChatController = ChatController chatStore :: SQLiteStore, idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, - outputQ :: TBQueue (CorrId, ChatResponse), + outputQ :: TBQueue (Maybe CorrId, ChatResponse), notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO (), chatLock :: TMVar (), @@ -64,7 +69,11 @@ data ChatController = ChatController } data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown - deriving (Show) + deriving (Show, Generic) + +instance ToJSON HelpSection where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "HS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "HS" data ChatCommand = ChatHelp HelpSection @@ -92,9 +101,9 @@ data ChatCommand | SendGroupMessage GroupName ByteString | SendFile ContactName FilePath | SendGroupFile GroupName FilePath - | ReceiveFile Int64 (Maybe FilePath) - | CancelFile Int64 - | FileStatus Int64 + | ReceiveFile FileTransferId (Maybe FilePath) + | CancelFile FileTransferId + | FileStatus FileTransferId | ShowProfile | UpdateProfile Profile | QuitChat @@ -102,107 +111,119 @@ data ChatCommand deriving (Show) data ChatResponse - = CRNewChatItem AChatItem - | CRCommandAccepted CorrId + = CRNewChatItem {chatItem :: AChatItem} + | CRCmdAccepted {corr :: CorrId} | CRChatHelp HelpSection | CRWelcome User - | CRGroupCreated GroupInfo - | CRGroupMembers Group - | CRContactsList [Contact] - | CRUserContactLink ConnReqContact - | CRContactRequestRejected ContactName -- TODO - | CRUserAcceptedGroupSent GroupInfo - | CRUserDeletedMember GroupInfo GroupMember - | CRGroupsList [GroupInfo] - | CRSentGroupInvitation GroupInfo Contact - | CRFileTransferStatus (FileTransfer, [Integer]) - | CRUserProfile Profile + | CRGroupCreated {groupInfo :: GroupInfo} + | CRGroupMembers {group :: Group} + | CRContactsList {contacts :: [Contact]} + | CRUserContactLink {connReqContact :: ConnReqContact} + | CRContactRequestRejected {contactName :: ContactName} -- TODO + | 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 + | CRInvitation {connReqInvitation :: ConnReqInvitation} | CRSentConfirmation | CRSentInvitation | CRContactUpdated {fromContact :: Contact, toContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} - | CRContactDeleted ContactName -- TODO - | CRUserContactLinkCreated ConnReqContact + | CRContactDeleted {contactName :: ContactName} -- TODO + | CRUserContactLinkCreated {connReqContact :: ConnReqContact} | CRUserContactLinkDeleted - | CRReceivedContactRequest ContactName Profile -- TODO what is the entity here? - | CRAcceptingContactRequest ContactName -- TODO - | CRLeftMemberUser GroupInfo - | CRGroupDeletedUser GroupInfo - | CRRcvFileAccepted RcvFileTransfer FilePath - | CRRcvFileAcceptedSndCancelled RcvFileTransfer - | CRRcvFileStart RcvFileTransfer - | CRRcvFileComplete RcvFileTransfer - | CRRcvFileCancelled RcvFileTransfer - | CRRcvFileSndCancelled RcvFileTransfer - | CRSndFileStart SndFileTransfer - | CRSndFileComplete SndFileTransfer - | CRSndFileCancelled SndFileTransfer - | CRSndFileRcvCancelled SndFileTransfer - | CRSndGroupFileCancelled [SndFileTransfer] + | CRReceivedContactRequest {contactName :: ContactName, profile :: Profile} -- TODO what is the entity here? + | CRAcceptingContactRequest {contactName :: ContactName} -- TODO + | 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} - | CRContactConnected Contact - | CRContactAnotherClient Contact - | CRContactDisconnected Contact - | CRContactSubscribed Contact - | CRContactSubError Contact ChatError - | CRGroupInvitation GroupInfo - | CRReceivedGroupInvitation GroupInfo Contact GroupMemberRole - | CRUserJoinedGroup GroupInfo - | CRJoinedGroupMember GroupInfo GroupMember - | CRJoinedGroupMemberConnecting {group :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} - | CRConnectedToGroupMember GroupInfo GroupMember - | CRDeletedMember {group :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} - | CRDeletedMemberUser GroupInfo GroupMember - | CRLeftMember GroupInfo GroupMember - | CRGroupEmpty GroupInfo - | CRGroupRemoved GroupInfo - | CRGroupDeleted GroupInfo GroupMember - | CRMemberSubError GroupInfo ContactName ChatError -- TODO Contact? or GroupMember? - | CRGroupSubscribed GroupInfo - | CRSndFileSubError SndFileTransfer ChatError - | CRRcvFileSubError RcvFileTransfer ChatError + | 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 - | CRMessageError Text Text - | CRChatCmdError ChatError - | CRChatError ChatError - deriving (Show) + | 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) + deriving (Show, Exception, Generic) + +instance ToJSON ChatError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" data ChatErrorType = CEGroupUserRole | CEInvalidConnReq - | CEContactGroups ContactName [GroupName] - | CEGroupContactRole ContactName - | CEGroupDuplicateMember ContactName + | CEContactGroups {contactName :: ContactName, groupNames :: [GroupName]} + | CEGroupContactRole {contactName :: ContactName} + | CEGroupDuplicateMember {contactName :: ContactName} | CEGroupDuplicateMemberId - | CEGroupNotJoined GroupInfo + | CEGroupNotJoined {groupInfo :: GroupInfo} | CEGroupMemberNotActive | CEGroupMemberUserRemoved - | CEGroupMemberNotFound ContactName - | CEGroupMemberIntroNotFound ContactName - | CEGroupCantResendInvitation GroupInfo 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 - | CECommandError String - deriving (Show, Exception) + | CECommandError {message :: String} + deriving (Show, Exception, Generic) + +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) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2f86c8c2ed..0c0a45c0f2 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -13,7 +13,7 @@ module Simplex.Chat.Messages where -import Data.Aeson (FromJSON, ToJSON, (.=)) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB @@ -27,11 +27,13 @@ import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics +import GHC.Generics (Generic) import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgIntegrity, MsgMeta (..), serializeMsgIntegrity) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgIntegrity, MsgMeta (..)) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) data ChatType = CTDirect | CTGroup @@ -43,6 +45,24 @@ data ChatInfo (c :: ChatType) where deriving instance Show (ChatInfo c) +data JSONChatInfo + = JCInfoDirect {contact :: Contact} + | JCInfoGroup {groupInfo :: GroupInfo} + 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 + type ChatItemData d = (CIMeta d, CIContent d) data ChatItem (c :: ChatType) (d :: MsgDirection) where @@ -52,6 +72,26 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) where deriving instance Show (ChatItem c d) +data JSONChatItem d + = JCItemDirect {meta :: CIMeta d, content :: CIContent d} + | JCItemSndGroup {meta :: CIMeta d, content :: CIContent d} + | JCItemRcvGroup {member :: GroupMember, meta :: CIMeta d, content :: CIContent d} + deriving (Generic) + +instance ToJSON (JSONChatItem d) where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCItem" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCItem" + +instance ToJSON (ChatItem c d) where + toJSON = J.toJSON . jsonChatItem + toEncoding = J.toEncoding . jsonChatItem + +jsonChatItem :: ChatItem c d -> JSONChatItem d +jsonChatItem = \case + DirectChatItem meta cic -> JCItemDirect meta cic + SndGroupChatItem meta cic -> JCItemSndGroup meta cic + RcvGroupChatItem m meta cic -> JCItemRcvGroup m meta cic + data CChatItem c = forall d. CChatItem (SMsgDirection d) (ChatItem c d) deriving instance Show (CChatItem c) @@ -92,19 +132,50 @@ data AChatItem = forall c d. AChatItem (SChatType c) (SMsgDirection d) (ChatInfo 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) where CISndMeta :: CIMetaProps -> CIMeta 'MDSnd CIRcvMeta :: CIMetaProps -> MsgIntegrity -> CIMeta 'MDRcv deriving instance Show (CIMeta d) +instance ToJSON (CIMeta d) where + toJSON = J.toJSON . jsonCIMeta + toEncoding = J.toEncoding . jsonCIMeta + +data JSONCIMeta + = JCIMetaSnd {meta :: CIMetaProps} + | JCIMetaRcv {meta :: CIMetaProps, integrity :: MsgIntegrity} + deriving (Generic) + +instance ToJSON JSONCIMeta where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIMeta" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIMeta" + +jsonCIMeta :: CIMeta d -> JSONCIMeta +jsonCIMeta = \case + CISndMeta meta -> JCIMetaSnd meta + CIRcvMeta meta integrity -> JCIMetaRcv meta integrity + data CIMetaProps = CIMetaProps { itemId :: ChatItemId, itemTs :: ChatItemTs, localItemTs :: ZonedTime, createdAt :: UTCTime } - deriving (Show) + deriving (Show, Generic, FromJSON) + +instance ToJSON CIMetaProps where toEncoding = J.genericToEncoding J.defaultOptions type ChatItemId = Int64 @@ -120,26 +191,24 @@ deriving instance Show (CIContent d) instance ToField (CIContent d) where toField = toField . decodeLatin1 . LB.toStrict . J.encode instance ToJSON (CIContent d) where - toJSON = J.toJSON . ciContentToJSON - toEncoding = J.toEncoding . ciContentToJSON + toJSON = J.toJSON . jsonCIContent + toEncoding = J.toEncoding . jsonCIContent -data CIContentJSON = CIContentJSON - { tag :: Text, - subTag :: Maybe Text, - args :: J.Value - } - deriving (Generic, FromJSON) +data JSONCIContent + = JCIMsgContent {msgContent :: MsgContent} + | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} + | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} + deriving (Generic) -instance ToJSON CIContentJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON JSONCIContent where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" -ciContentToJSON :: CIContent d -> CIContentJSON -ciContentToJSON = \case - CIMsgContent mc -> o "content" "" $ J.object ["content" .= mc] - CISndFileInvitation fId fPath -> o "sndFile" "invitation" $ J.object ["fileId" .= fId, "filePath" .= fPath] - CIRcvFileInvitation ft -> o "rcvFile" "invitation" $ J.object ["fileTransfer" .= ft] - where - o tag "" args = CIContentJSON {tag, subTag = Nothing, args} - o tag st args = CIContentJSON {tag, subTag = Just st, args} +jsonCIContent :: CIContent d -> JSONCIContent +jsonCIContent = \case + CIMsgContent mc -> JCIMsgContent mc + CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath + CIRcvFileInvitation ft -> JCIRcvFileInvitation ft ciContentToText :: CIContent d -> Text ciContentToText = \case @@ -241,7 +310,7 @@ instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOpti msgMetaToJson :: MsgMeta -> MsgMetaJSON msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = MsgMetaJSON - { integrity = (decodeLatin1 . serializeMsgIntegrity) integrity, + { integrity = (decodeLatin1 . strEncode) integrity, rcvId, rcvTs, serverId = (decodeLatin1 . B64.encode) serverId, diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0f9940bcc0..cdcf40cdd3 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -13,19 +13,17 @@ import Control.Monad.Reader import Data.Aeson (ToJSON (..), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE -import qualified Data.ByteString.Base64.URL as U 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 +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.Chat.View import Simplex.Messaging.Protocol (CorrId (..)) foreign export ccall "chat_init_store" cChatInitStore :: CString -> IO (StablePtr ChatStore) @@ -97,19 +95,19 @@ getActiveUser_ st = find activeUser <$> getUsers st -- | returns JSON in the form `{"user": }` or `{}` chatGetUser :: ChatStore -> IO JSONString chatGetUser ChatStore {chatStore} = - maybe "{}" (jsonObject . ("user" .=)) <$> getActiveUser_ chatStore + maybe "{}" userObject <$> getActiveUser_ chatStore -- | returns JSON in the form `{"user": }` or `{"error": ""}` chatCreateUser :: ChatStore -> JSONString -> IO JSONString chatCreateUser ChatStore {chatStore} profileJson = case J.eitherDecodeStrict' $ B.pack profileJson of - Left e -> err e - Right p -> - runExceptT (createUser chatStore p True) >>= \case - Right user -> pure . jsonObject $ "user" .= user - Left e -> err e + Left e -> pure $ err e + Right p -> either err userObject <$> runExceptT (createUser chatStore p True) where - err e = pure . jsonObject $ "error" .= show e + err e = jsonObject $ "error" .= show e + +userObject :: User -> JSONString +userObject user = jsonObject $ "user" .= user chatStart :: ChatStore -> IO ChatController chatStart ChatStore {dbFilePrefix, chatStore} = do @@ -119,33 +117,19 @@ chatStart ChatStore {dbFilePrefix, chatStore} = do pure cc chatSendCmd :: ChatController -> String -> IO JSONString -chatSendCmd cc s = crToJSON (CorrId "") <$> runReaderT (execChatCommand s) cc +chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc chatRecvMsg :: ChatController -> IO JSONString chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where - json (corrId, resp) = crToJSON corrId resp + json (corr, resp) = LB.unpack $ J.encode APIResponse {corr, resp} jsonObject :: J.Series -> JSONString jsonObject = LB.unpack . JE.encodingToLazyByteString . J.pairs -crToJSON :: CorrId -> ChatResponse -> JSONString -crToJSON corrId = LB.unpack . J.encode . crToAPI corrId - -crToAPI :: CorrId -> ChatResponse -> APIResponse -crToAPI (CorrId cId) = \case - CRUserProfile p -> api "profile" $ J.object ["profile" .= p] - r -> api "terminal" $ J.object ["output" .= serializeChatResponse r] - where - corr = if B.null cId then Nothing else Just . B.unpack $ U.encode cId - api tag args = APIResponse {corr, tag, args} - -data APIResponse = APIResponse - { -- | optional correlation ID for async command responses - corr :: Maybe String, - tag :: String, - args :: J.Value - } +data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} deriving (Generic) -instance ToJSON APIResponse where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +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/Protocol.hs b/src/Simplex/Chat/Protocol.hs index fe667fac7d..8e3f8a776d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -24,7 +24,7 @@ import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics +import GHC.Generics (Generic) import Simplex.Chat.Types import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String @@ -105,6 +105,8 @@ 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) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index b7091e0d5b..ab9a93afcd 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1,6 +1,7 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -114,6 +115,8 @@ 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 qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import Data.Either (rights) @@ -128,6 +131,7 @@ import Data.Time.Clock (UTCTime, getCurrentTime) 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_pending_group_messages @@ -138,7 +142,8 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMe 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 Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (liftIOEither, (<$$>)) import System.FilePath (takeFileName) import UnliftIO.STM @@ -167,7 +172,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 = SEInternal $ show e insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" @@ -219,11 +224,8 @@ createDirectConnection st userId agentConnId = createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> IO Connection createContactConnection_ db userId = 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 +createConnection_ db userId connType entityId acId viaContact connLevel = do createdAt <- getCurrentTime DB.execute db @@ -233,25 +235,10 @@ createConnection_ db userId connType entityId agentConnId viaContact connLevel = contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?); |] - (insertConnParams createdAt) + (userId, acId, connLevel, viaContact, ConnNew, connType, ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, createdAt) 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} 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 () @@ -652,9 +639,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 @@ -795,7 +782,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = Nothing -> if connType == ConnContact then pure $ RcvDirectMsgConnection c Nothing - else throwError $ SEInternal $ "connection " <> bshow connType <> " without entity" + else throwError $ SEInternal $ "connection " <> show connType <> " without entity" Just entId -> case connType of ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ db entId c @@ -818,7 +805,7 @@ getConnectionEntity 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 @@ -1432,14 +1419,14 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = 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} aConnId 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) fileId <- insertedRowId db - Connection {connId} <- createSndFileConnection_ db userId fileId aConnId + 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 {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId aConnId} + pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId acId} createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize chunkSize = @@ -1990,7 +1977,7 @@ 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 . SEInternal $ show e randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) @@ -2012,9 +1999,13 @@ data StoreError | SERcvFileNotFound Int64 | SEFileNotFound Int64 | SERcvFileInvalid Int64 - | SEConnectionNotFound ConnId + | SEConnectionNotFound AgentConnId | SEIntroNotFound | SEUniqueID - | SEInternal ByteString + | SEInternal String | SENoMsgDelivery Int64 AgentMsgId - deriving (Show, Exception) + 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/Types.hs b/src/Simplex/Chat/Types.hs index 1bfcdfe32b..014ca47d31 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1,17 +1,21 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# 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 @@ -21,16 +25,17 @@ import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) -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 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.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) class IsContact a where @@ -57,7 +62,7 @@ data User = User } deriving (Show, Generic, FromJSON) -instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions type UserId = Int64 @@ -68,13 +73,17 @@ data Contact = Contact activeConn :: Connection, viaGroup :: Maybe Int64 } - 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, @@ -96,8 +105,10 @@ type ContactName = Text type GroupName = Text -data Group = Group GroupInfo [GroupMember] - deriving (Eq, Show) +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, @@ -105,7 +116,9 @@ data GroupInfo = GroupInfo groupProfile :: GroupProfile, membership :: GroupMember } - 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 @@ -116,7 +129,7 @@ data Profile = Profile } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions data GroupProfile = GroupProfile { displayName :: GroupName, @@ -124,7 +137,7 @@ data GroupProfile = GroupProfile } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, @@ -134,7 +147,7 @@ data GroupInvitation = GroupInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions data MemberIdRole = MemberIdRole { memberId :: MemberId, @@ -142,7 +155,7 @@ data MemberIdRole = MemberIdRole } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, @@ -150,7 +163,7 @@ data IntroInvitation = IntroInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON IntroInvitation where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON IntroInvitation where toEncoding = J.genericToEncoding J.defaultOptions data MemberInfo = MemberInfo { memberId :: MemberId, @@ -159,7 +172,7 @@ data MemberInfo = MemberInfo } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptions memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile} = @@ -185,15 +198,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, @@ -224,8 +239,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) @@ -311,26 +333,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 @@ -346,9 +372,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 @@ -378,34 +410,32 @@ 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 :: FileTransferId, @@ -418,7 +448,9 @@ data SndFileTransfer = SndFileTransfer agentConnId :: AgentConnId, fileStatus :: FileStatus } - deriving (Eq, Show) + deriving (Eq, Show, Generic) + +instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions type FileTransferId = Int64 @@ -427,24 +459,9 @@ data FileInvitation = FileInvitation 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 :: FileTransferId, @@ -455,7 +472,7 @@ data RcvFileTransfer = RcvFileTransfer } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions data RcvFileStatus = RFSNew @@ -463,38 +480,14 @@ data RcvFileStatus | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo | RFSCancelled RcvFileInfo - deriving (Eq, Show) + deriving (Eq, Show, Generic) instance FromJSON RcvFileStatus where - parseJSON = J.withObject "RcvFileStatus" $ \v -> do - let rfs mk = mk <$> v .: "fileInfo" - v .: "status" >>= \case - ("new" :: Text) -> pure RFSNew - "accepted" -> rfs RFSAccepted - "connected" -> rfs RFSConnected - "complete" -> rfs RFSComplete - "cancelled" -> rfs RFSCancelled - _ -> fail "bad RcvFileStatus" + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" instance ToJSON RcvFileStatus where - toJSON s = J.object $ ["status" .= rfsTag s, "fileInfo" .= rfsInfo s] - toEncoding s = J.pairs $ ("status" .= rfsTag s <> "fileInfo" .= rfsInfo s) - -rfsTag :: RcvFileStatus -> Text -rfsTag = \case - RFSNew -> "new" - RFSAccepted _ -> "accepted" - RFSConnected _ -> "connected" - RFSComplete _ -> "complete" - RFSCancelled _ -> "cancelled" - -rfsInfo :: RcvFileStatus -> Maybe RcvFileInfo -rfsInfo = \case - RFSNew -> Nothing - RFSAccepted info -> Just info - RFSConnected info -> Just info - RFSComplete info -> Just info - RFSCancelled info -> Just info + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" data RcvFileInfo = RcvFileInfo { filePath :: FilePath, @@ -503,7 +496,7 @@ data RcvFileInfo = RcvFileInfo } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions newtype AgentConnId = AgentConnId ConnId deriving (Eq, Show) @@ -524,38 +517,39 @@ instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f instance ToField AgentConnId where toField (AgentConnId m) = toField m -data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer - deriving (Show) +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 -instance FromJSON FileStatus where - parseJSON = J.withText "FileStatus" $ maybe (fail "bad FileStatus") pure . fileStatusT +instance FromJSON FileStatus where parseJSON = textParseJSON "FileStatus" instance ToJSON FileStatus where - toJSON = J.String . serializeFileStatus - toEncoding = JE.text . serializeFileStatus + toJSON = J.String . encodeText + toEncoding = JE.text . encodeText -fileStatusT :: Text -> Maybe FileStatus -fileStatusT = \case - "new" -> Just FSNew - "accepted" -> Just FSAccepted - "connected" -> Just FSConnected - "complete" -> Just FSComplete - "cancelled" -> Just FSCancelled - _ -> Nothing - -serializeFileStatus :: FileStatus -> Text -serializeFileStatus = \case - FSNew -> "new" - FSAccepted -> "accepted" - FSConnected -> "connected" - FSComplete -> "complete" - FSCancelled -> "cancelled" +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) @@ -566,7 +560,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact data Connection = Connection { connId :: Int64, - agentConnId :: ConnId, + agentConnId :: AgentConnId, connLevel :: Int, viaContact :: Maybe Int64, connType :: ConnType, @@ -574,7 +568,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) @@ -593,54 +594,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, @@ -695,3 +704,10 @@ serializeIntroStatus = \case data Notification = Notification {title :: Text, text :: Text} type JSONString = String + +class TextEncoding a where + encodeText :: a -> Text + decodeText :: Text -> Maybe a + +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/View.hs b/src/Simplex/Chat/View.hs index 8ae4758537..70857e0020 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -34,7 +34,7 @@ serializeChatResponse = unlines . map unStyle . responseToView "" responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item - CRCommandAccepted _ -> r [] + CRCmdAccepted _ -> r [] CRChatHelp section -> case section of HSMain -> r chatHelpInfo HSFiles -> r filesHelpInfo @@ -361,7 +361,7 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = [status <> " sending " <> sndFile ft <> " to " <> ttyContact c] sndFile :: SndFileTransfer -> StyledString -sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName +sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName viewReceivedFileInvitation :: StyledString -> CIMetaProps -> RcvFileTransfer -> MsgIntegrity -> [StyledString] viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) @@ -389,10 +389,10 @@ receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = [status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c] 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 <> ")" viewFileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString] viewFileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) = diff --git a/stack.yaml b/stack.yaml index 8942dffea1..0e2d618983 100644 --- a/stack.yaml +++ b/stack.yaml @@ -41,7 +41,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: b777a4fd93f888d549edf1877583fb7fc0e0196f + commit: 6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/haskell-terminal commit: f708b00009b54890172068f168bf98508ffcd495 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index a8aff63628..5ee996bdbd 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} From 28ee40074a4a0adfa74ec3aa68dc236cfee5128e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 26 Jan 2022 22:49:44 +0000 Subject: [PATCH 15/82] update sha256map.nix --- sha256map.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sha256map.nix b/sha256map.nix index 7134fe7564..cc95f1ca7f 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "git://github.com/simplex-chat/simplexmq.git"."6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b" = "0cnbc9swdzb29j3pv4z64w26sq8dsp4ixnnv5bbf5k6dz9bwl9zm"; + "git://github.com/simplex-chat/simplexmq.git"."6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b" = "0yhxngrvis2ykcrx2mzin1c2bch1p7r6m4lqazdybrkas0p349qc"; "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; } From 37cfb9321754e45011c73953f0d9f0b638b8d063 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 27 Jan 2022 22:01:15 +0000 Subject: [PATCH 16/82] switch to JSON single field encodings for sum types to align with Swift enums (#229) --- src/Simplex/Chat.hs | 6 ++-- src/Simplex/Chat/Controller.hs | 31 +++++++++---------- src/Simplex/Chat/Messages.hs | 55 +++++++++++++++++++++------------- src/Simplex/Chat/Store.hs | 31 +++++++++---------- src/Simplex/Chat/Terminal.hs | 10 +++---- src/Simplex/Chat/Types.hs | 23 +++++++------- src/Simplex/Chat/Util.hs | 16 ++++++++++ src/Simplex/Chat/View.hs | 10 +++---- 8 files changed, 108 insertions(+), 74 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8a25a5b584..b8af2d6cbd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -777,7 +777,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do toView $ CRConnectedToGroupMember gInfo m - let g = groupName gInfo + let g = groupName' gInfo setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" @@ -812,7 +812,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msgId msgMeta = do ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIMsgContent mc) toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci - let g = groupName gInfo + let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc setActive $ ActiveG g @@ -834,7 +834,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvFileInvitation ft) withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci - let g = groupName gInfo + let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG g diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index dff3e67ab8..fbdbae3054 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -23,11 +23,12 @@ import Numeric.Natural import Simplex.Chat.Messages import Simplex.Chat.Store (StoreError) import Simplex.Chat.Types +import Simplex.Chat.Util (enumJSON, singleFieldJSON) import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix) import Simplex.Messaging.Protocol (CorrId) import System.IO (Handle) import UnliftIO.STM @@ -72,8 +73,8 @@ data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown deriving (Show, Generic) instance ToJSON HelpSection where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "HS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "HS" + toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" data ChatCommand = ChatHelp HelpSection @@ -113,8 +114,8 @@ data ChatCommand data ChatResponse = CRNewChatItem {chatItem :: AChatItem} | CRCmdAccepted {corr :: CorrId} - | CRChatHelp HelpSection - | CRWelcome User + | CRChatHelp {helpSection :: HelpSection} + | CRWelcome {user :: User} | CRGroupCreated {groupInfo :: GroupInfo} | CRGroupMembers {group :: Group} | CRContactsList {contacts :: [Contact]} @@ -181,19 +182,19 @@ data ChatResponse deriving (Show, Generic) instance ToJSON ChatResponse where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "CR" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "CR" data ChatError - = ChatError ChatErrorType - | ChatErrorMessage String - | ChatErrorAgent AgentErrorType - | ChatErrorStore StoreError + = ChatError {errorType :: ChatErrorType} + | ChatErrorMessage {errorMessage :: String} + | 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" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "Chat" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "Chat" data ChatErrorType = CEGroupUserRole @@ -222,8 +223,8 @@ data ChatErrorType deriving (Show, Exception, Generic) instance ToJSON ChatErrorType where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "CE" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "CE" type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 0c0a45c0f2..349d231b72 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -30,10 +30,11 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Util (enumJSON, singleFieldJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgIntegrity, MsgMeta (..)) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix) import Simplex.Messaging.Protocol (MsgBody) data ChatType = CTDirect | CTGroup @@ -51,8 +52,8 @@ data JSONChatInfo deriving (Generic) instance ToJSON JSONChatInfo where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCInfo" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCInfo" instance ToJSON (ChatInfo c) where toJSON = J.toJSON . jsonChatInfo @@ -73,24 +74,26 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) where deriving instance Show (ChatItem c d) data JSONChatItem d - = JCItemDirect {meta :: CIMeta d, content :: CIContent d} - | JCItemSndGroup {meta :: CIMeta d, content :: CIContent d} - | JCItemRcvGroup {member :: GroupMember, meta :: CIMeta d, content :: CIContent d} + = JCItemDirect {dir :: MsgDirection, meta :: CIMeta d, content :: CIContent d} + | JCItemSndGroup {dir :: MsgDirection, meta :: CIMeta d, content :: CIContent d} + | JCItemRcvGroup {dir :: MsgDirection, member :: GroupMember, meta :: CIMeta d, content :: CIContent d} deriving (Generic) instance ToJSON (JSONChatItem d) where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCItem" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCItem" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCItem" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCItem" -instance ToJSON (ChatItem c d) where +instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.toJSON . jsonChatItem toEncoding = J.toEncoding . jsonChatItem -jsonChatItem :: ChatItem c d -> JSONChatItem d +jsonChatItem :: forall c d. MsgDirectionI d => ChatItem c d -> JSONChatItem d jsonChatItem = \case - DirectChatItem meta cic -> JCItemDirect meta cic - SndGroupChatItem meta cic -> JCItemSndGroup meta cic - RcvGroupChatItem m meta cic -> JCItemRcvGroup m meta cic + DirectChatItem meta cic -> JCItemDirect md meta cic + SndGroupChatItem meta cic -> JCItemSndGroup md meta cic + RcvGroupChatItem m meta cic -> JCItemRcvGroup md m meta cic + where + md = toMsgDirection $ msgDirection @d data CChatItem c = forall d. CChatItem (SMsgDirection d) (ChatItem c d) @@ -128,7 +131,7 @@ data AChatPreview = forall c. AChatPreview (SChatType c) (ChatInfo c) (Maybe (CC deriving instance Show AChatPreview -- | 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) +data AChatItem = forall c d. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem @@ -139,7 +142,7 @@ instance ToJSON AChatItem where data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} deriving (Generic) -instance ToJSON (JSONAnyChatItem c d) where +instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -159,8 +162,8 @@ data JSONCIMeta deriving (Generic) instance ToJSON JSONCIMeta where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIMeta" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIMeta" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCIMeta" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCIMeta" jsonCIMeta :: CIMeta d -> JSONCIMeta jsonCIMeta = \case @@ -201,8 +204,8 @@ data JSONCIContent deriving (Generic) instance ToJSON JSONCIContent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" jsonCIContent :: CIContent d -> JSONCIContent jsonCIContent = \case @@ -251,7 +254,14 @@ data PendingGroupMessage = PendingGroupMessage type MessageId = Int64 data MsgDirection = MDRcv | MDSnd - deriving (Show) + 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" data SMsgDirection (d :: MsgDirection) where SMDRcv :: SMsgDirection 'MDRcv @@ -271,6 +281,11 @@ instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd +toMsgDirection :: SMsgDirection d -> MsgDirection +toMsgDirection = \case + SMDRcv -> MDRcv + SMDSnd -> MDSnd + instance ToField MsgDirection where toField = toField . msgDirectionInt msgDirectionInt :: MsgDirection -> Int diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index ab9a93afcd..6711a41dd4 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -138,11 +138,12 @@ import Simplex.Chat.Migrations.M20220122_pending_group_messages import Simplex.Chat.Migrations.M20220125_chat_items import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Util (singleFieldJSON) 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.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix) import Simplex.Messaging.Util (liftIOEither, (<$$>)) import System.FilePath (takeFileName) import UnliftIO.STM @@ -1984,28 +1985,28 @@ randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGe data StoreError = SEDuplicateName - | SEContactNotFound ContactName - | SEContactNotReady ContactName + | SEContactNotFound {contactName :: ContactName} + | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink | SEUserContactLinkNotFound - | SEContactRequestNotFound ContactName - | SEGroupNotFound GroupName + | SEContactRequestNotFound {contactName :: ContactName} + | SEGroupNotFound {groupName :: GroupName} | SEGroupWithoutUser | SEDuplicateGroupMember | SEGroupAlreadyJoined | SEGroupInvitationNotFound - | SESndFileNotFound Int64 - | SESndFileInvalid Int64 - | SERcvFileNotFound Int64 - | SEFileNotFound Int64 - | SERcvFileInvalid Int64 - | SEConnectionNotFound AgentConnId + | SESndFileNotFound {fileId :: FileTransferId} + | SESndFileInvalid {fileId :: FileTransferId} + | SERcvFileNotFound {fileId :: FileTransferId} + | SEFileNotFound {fileId :: FileTransferId} + | SERcvFileInvalid {fileId :: FileTransferId} + | SEConnectionNotFound {agentConnId :: AgentConnId} | SEIntroNotFound | SEUniqueID - | SEInternal String - | SENoMsgDelivery Int64 AgentMsgId + | SEInternal {message :: String} + | SENoMsgDelivery {connId :: Int64, agentMsgId :: AgentMsgId} deriving (Show, Exception, Generic) instance ToJSON StoreError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "SE" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "SE" diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 648db4a561..8fd34ff325 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -25,12 +25,12 @@ simplexChat cfg opts t sendNotification' <- initializeNotifications let f = chatStoreFile $ dbFilePrefix opts st <- createStore f $ dbPoolSize cfg - user <- getCreateActiveUser st + u <- getCreateActiveUser st ct <- newChatTerminal t - cc <- newChatController st user cfg opts sendNotification' - runSimplexChat user ct cc + cc <- newChatController st u cfg opts sendNotification' + runSimplexChat u ct cc runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () -runSimplexChat user ct = runReaderT $ do - whenM (asks firstTime) . liftIO . printToTerminal ct $ chatWelcome user +runSimplexChat u ct = runReaderT $ do + whenM (asks firstTime) . liftIO . printToTerminal ct $ chatWelcome u raceAny_ [runTerminalInput ct, runTerminalOutput ct, runInputLoop ct, runChatController] diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 014ca47d31..b413d28071 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -32,10 +32,11 @@ import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) +import Simplex.Chat.Util (singleFieldJSON) import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (dropPrefix) import Simplex.Messaging.Util ((<$?>)) class IsContact a where @@ -120,8 +121,8 @@ data GroupInfo = GroupInfo instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions -groupName :: GroupInfo -> GroupName -groupName GroupInfo {localDisplayName = g} = g +groupName' :: GroupInfo -> GroupName +groupName' GroupInfo {localDisplayName = g} = g data Profile = Profile { displayName :: ContactName, @@ -243,11 +244,11 @@ data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show, Generic) instance FromJSON InvitedBy where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" + parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "IB" instance ToJSON InvitedBy where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "IB" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "IB" toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy toInvitedBy userCtId (Just ctId) @@ -483,11 +484,11 @@ data RcvFileStatus deriving (Eq, Show, Generic) instance FromJSON RcvFileStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" + parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "RFS" instance ToJSON RcvFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "RFS" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "RFS" data RcvFileInfo = RcvFileInfo { filePath :: FilePath, @@ -521,8 +522,8 @@ data FileTransfer = FTSnd {sndFileTransfers :: [SndFileTransfer]} | FTRcv RcvFil deriving (Show, Generic) instance ToJSON FileTransfer where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "FT" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "FT" data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 1244774c50..7be20f54e0 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -1,6 +1,7 @@ module Simplex.Chat.Util where import Control.Monad (when) +import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Text.Encoding (decodeUtf8With) @@ -18,3 +19,18 @@ whenM ba a = ba >>= (`when` a) unlessM :: Monad m => m Bool -> m () -> m () unlessM b = ifM b $ pure () + +enumJSON :: (String -> String) -> J.Options +enumJSON tagModifier = + J.defaultOptions + { J.constructorTagModifier = tagModifier, + J.allNullaryToStringTag = True + } + +singleFieldJSON :: (String -> String) -> J.Options +singleFieldJSON tagModifier = + J.defaultOptions + { J.constructorTagModifier = tagModifier, + J.sumEncoding = J.ObjectWithSingleField, + J.omitNothingFields = True + } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 70857e0020..507a51f803 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -99,7 +99,7 @@ responseToView cmd = \case 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"] + 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 -> @@ -203,11 +203,11 @@ viewCannotResendInvitation GroupInfo {localDisplayName = gn} c = viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewReceivedGroupInvitation g c role = [ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role), - "use " <> highlight ("/j " <> groupName g) <> " to accept" + "use " <> highlight ("/j " <> groupName' g) <> " to accept" ] groupPreserved :: GroupInfo -> [StyledString] -groupPreserved g = ["use " <> highlight ("/d #" <> groupName g) <> " to delete the group"] +groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"] connectedMember :: GroupMember -> StyledString connectedMember m = case memberCategory m of @@ -446,7 +446,7 @@ viewChatError = \case 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 #" <> groupName 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"] @@ -514,7 +514,7 @@ ttyGroup :: GroupName -> StyledString ttyGroup g = styled (Colored Blue) $ "#" <> g ttyGroup' :: GroupInfo -> StyledString -ttyGroup' = ttyGroup . groupName +ttyGroup' = ttyGroup . groupName' ttyGroups :: [GroupName] -> StyledString ttyGroups [] = "" From edc9560d3616980ab0bcd8183d3984517c85c85b Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 28 Jan 2022 11:52:10 +0400 Subject: [PATCH 17/82] getDirectChat (#227) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 69 ++++++++++----------- src/Simplex/Chat/Messages.hs | 113 ++++++++++++++++++++++------------- src/Simplex/Chat/Store.hs | 82 ++++++++++++++++++++----- src/Simplex/Chat/View.hs | 22 +++---- 4 files changed, 180 insertions(+), 106 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b8af2d6cbd..894d142925 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -31,12 +31,11 @@ import Data.Int (Int64) import Data.List (find) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromJust, isJust, mapMaybe) +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 (utcToLocalZonedTime) import Data.Word (Word32) import Simplex.Chat.Controller import Simplex.Chat.Messages @@ -287,7 +286,9 @@ processChatCommand user@User {userId, profile} = \case setActive $ ActiveG gName -- this is a hack as we have multiple direct messages instead of one per group let ciContent = CISndFileInvitation fileId f - ciMeta@CIMetaProps {itemId} <- saveChatItem userId (CDSndGroup gInfo) Nothing ciContent + createdAt <- liftIO getCurrentTime + let ci = mkNewChatItem ciContent 0 createdAt createdAt + ciMeta@CIMetaProps {itemId} <- saveChatItem userId (CDSndGroup gInfo) ci withStore $ \st -> updateFileTransferChatItemId st fileId itemId pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ SndGroupChatItem (CISndMeta ciMeta) ciContent ReceiveFile fileId filePath_ -> do @@ -1161,54 +1162,44 @@ saveRcvMSG Connection {connId} agentMsgMeta msgBody = do sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd) sendDirectChatItem userId contact@Contact {activeConn} chatMsgEvent ciContent = do msgId <- sendDirectMessage activeConn chatMsgEvent - ciMeta <- saveChatItem userId (CDDirect contact) (Just msgId) ciContent + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDDirect contact) $ mkNewChatItem ciContent msgId createdAt createdAt pure $ DirectChatItem (CISndMeta 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 - ciMeta <- saveChatItem userId (CDSndGroup g) (Just msgId) ciContent + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDSndGroup g) $ mkNewChatItem ciContent msgId createdAt createdAt pure $ SndGroupChatItem (CISndMeta ciMeta) ciContent saveRcvDirectChatItem :: ChatMonad m => UserId -> Contact -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTDirect 'MDRcv) -saveRcvDirectChatItem userId ct msgId MsgMeta {integrity} ciContent = do - ciMeta <- saveChatItem userId (CDDirect ct) (Just msgId) ciContent - pure $ DirectChatItem (CIRcvMeta ciMeta integrity) ciContent +saveRcvDirectChatItem userId ct msgId MsgMeta {broker = (_, brokerTs)} ciContent = do + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDDirect ct) $ mkNewChatItem ciContent msgId brokerTs createdAt + pure $ DirectChatItem (CIRcvMeta ciMeta) ciContent saveRcvGroupChatItem :: ChatMonad m => UserId -> GroupInfo -> GroupMember -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem 'CTGroup 'MDRcv) -saveRcvGroupChatItem userId g m msgId MsgMeta {integrity} ciContent = do - ciMeta <- saveChatItem userId (CDRcvGroup g m) (Just msgId) ciContent - pure $ RcvGroupChatItem m (CIRcvMeta ciMeta integrity) ciContent +saveRcvGroupChatItem userId g m msgId MsgMeta {broker = (_, brokerTs)} ciContent = do + createdAt <- liftIO getCurrentTime + ciMeta <- saveChatItem userId (CDRcvGroup g m) $ mkNewChatItem ciContent msgId brokerTs createdAt + pure $ RcvGroupChatItem m (CIRcvMeta ciMeta) ciContent -saveChatItem :: ChatMonad m => UserId -> ChatDirection c d -> Maybe MessageId -> CIContent d -> m CIMetaProps -saveChatItem userId chatDirection msgId_ ciContent = do - ci@NewChatItem {itemTs, createdAt} <- mkNewChatItem msgId_ MDRcv Nothing ciContent - ciId <- withStore $ \st -> createNewChatItem st userId chatDirection ci - liftIO $ mkCIMetaProps ciId itemTs createdAt +saveChatItem :: (MsgDirectionI d, ChatMonad m) => UserId -> ChatDirection c d -> NewChatItem d -> m CIMetaProps +saveChatItem userId cd ci@NewChatItem {itemTs, itemText, createdAt} = do + ciId <- withStore $ \st -> createNewChatItem st userId cd ci + liftIO $ mkCIMetaProps ciId itemTs itemText createdAt -mkNewChatItem :: ChatMonad m => Maybe MessageId -> MsgDirection -> Maybe UTCTime -> CIContent d -> m (NewChatItem d) -mkNewChatItem createdByMsgId_ itemSent brokerTs_ itemContent = do - (itemTs, createdAt) <- timestamps - pure - NewChatItem - { createdByMsgId_, - itemSent, - itemTs, - itemContent, - itemText = ciContentToText itemContent, - createdAt - } - where - timestamps = do - createdAt <- liftIO getCurrentTime - if isJust brokerTs_ - then pure (fromJust brokerTs_, createdAt) -- if rcv use brokerTs - else pure (createdAt, createdAt) -- if snd use createdAt - -mkCIMetaProps :: ChatItemId -> ChatItemTs -> UTCTime -> IO CIMetaProps -mkCIMetaProps itemId itemTs createdAt = do - localItemTs <- utcToLocalZonedTime itemTs - pure CIMetaProps {itemId, itemTs, localItemTs, 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, + createdAt + } allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn confId msg = do diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 349d231b72..4d5c4d9344 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -20,9 +20,9 @@ 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) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) -import Data.Time.LocalTime (ZonedTime) +import Data.Time.LocalTime (ZonedTime, utcToLocalZonedTime) import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) @@ -31,7 +31,7 @@ import GHC.Generics (Generic) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (enumJSON, singleFieldJSON) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgIntegrity, MsgMeta (..)) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..)) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix) @@ -79,7 +79,7 @@ data JSONChatItem d | JCItemRcvGroup {dir :: MsgDirection, member :: GroupMember, meta :: CIMeta d, content :: CIContent d} deriving (Generic) -instance ToJSON (JSONChatItem d) where +instance MsgDirectionI d => ToJSON (JSONChatItem d) where toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCItem" toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCItem" @@ -102,9 +102,9 @@ deriving instance Show (CChatItem c) chatItemId :: ChatItem c d -> ChatItemId chatItemId = \case DirectChatItem (CISndMeta CIMetaProps {itemId}) _ -> itemId - DirectChatItem (CIRcvMeta CIMetaProps {itemId} _) _ -> itemId + DirectChatItem (CIRcvMeta CIMetaProps {itemId}) _ -> itemId SndGroupChatItem (CISndMeta CIMetaProps {itemId}) _ -> itemId - RcvGroupChatItem _ (CIRcvMeta CIMetaProps {itemId} _) _ -> itemId + RcvGroupChatItem _ (CIRcvMeta CIMetaProps {itemId}) _ -> itemId data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirect :: Contact -> ChatDirection 'CTDirect d @@ -112,8 +112,8 @@ data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDRcvGroup :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv data NewChatItem d = NewChatItem - { createdByMsgId_ :: Maybe MessageId, - itemSent :: MsgDirection, + { createdByMsgId :: Maybe MessageId, + itemSent :: SMsgDirection d, itemTs :: ChatItemTs, itemContent :: CIContent d, itemText :: Text, @@ -148,7 +148,7 @@ instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where data CIMeta (d :: MsgDirection) where CISndMeta :: CIMetaProps -> CIMeta 'MDSnd - CIRcvMeta :: CIMetaProps -> MsgIntegrity -> CIMeta 'MDRcv + CIRcvMeta :: CIMetaProps -> CIMeta 'MDRcv deriving instance Show (CIMeta d) @@ -158,7 +158,7 @@ instance ToJSON (CIMeta d) where data JSONCIMeta = JCIMetaSnd {meta :: CIMetaProps} - | JCIMetaRcv {meta :: CIMetaProps, integrity :: MsgIntegrity} + | JCIMetaRcv {meta :: CIMetaProps} deriving (Generic) instance ToJSON JSONCIMeta where @@ -168,16 +168,22 @@ instance ToJSON JSONCIMeta where jsonCIMeta :: CIMeta d -> JSONCIMeta jsonCIMeta = \case CISndMeta meta -> JCIMetaSnd meta - CIRcvMeta meta integrity -> JCIMetaRcv meta integrity + CIRcvMeta meta -> JCIMetaRcv meta data CIMetaProps = CIMetaProps { itemId :: ChatItemId, itemTs :: ChatItemTs, + itemText :: Text, localItemTs :: ZonedTime, createdAt :: UTCTime } deriving (Show, Generic, FromJSON) +mkCIMetaProps :: ChatItemId -> ChatItemTs -> Text -> UTCTime -> IO CIMetaProps +mkCIMetaProps itemId itemTs itemText createdAt = do + localItemTs <- utcToLocalZonedTime itemTs + pure CIMetaProps {itemId, itemTs, itemText, localItemTs, createdAt} + instance ToJSON CIMetaProps where toEncoding = J.genericToEncoding J.defaultOptions type ChatItemId = Int64 @@ -191,34 +197,55 @@ data CIContent (d :: MsgDirection) where deriving instance Show (CIContent d) -instance ToField (CIContent d) where toField = toField . decodeLatin1 . LB.toStrict . J.encode - -instance ToJSON (CIContent d) where - toJSON = J.toJSON . jsonCIContent - toEncoding = J.toEncoding . jsonCIContent - -data JSONCIContent - = JCIMsgContent {msgContent :: MsgContent} - | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} - | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} - deriving (Generic) - -instance ToJSON JSONCIContent where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" - -jsonCIContent :: CIContent d -> JSONCIContent -jsonCIContent = \case - CIMsgContent mc -> JCIMsgContent mc - CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath - CIRcvFileInvitation ft -> JCIRcvFileInvitation ft - ciContentToText :: CIContent d -> Text ciContentToText = \case CIMsgContent 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 MsgDirectionI d => ToField (CIContent d) where + toField = toField . decodeLatin1 . LB.toStrict . J.encode + +instance MsgDirectionI d => 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 + = JCIMsgContent {msgDir :: MsgDirection, msgContent :: MsgContent} + | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} + | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} + deriving (Generic) + +instance FromJSON JSONCIContent where + parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "JCI" + +instance ToJSON JSONCIContent where + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" + +jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent +jsonCIContent = \case + CIMsgContent mc -> JCIMsgContent md mc + CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath + CIRcvFileInvitation ft -> JCIRcvFileInvitation ft + where + md = toMsgDirection $ msgDirection @d + +aciContentJSON :: JSONCIContent -> ACIContent +aciContentJSON = \case + JCIMsgContent md mc -> case md of + MDSnd -> ACIContent SMDSnd $ CIMsgContent mc + MDRcv -> ACIContent SMDRcv $ CIMsgContent 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 @@ -263,6 +290,8 @@ 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 @@ -274,6 +303,13 @@ instance TestEquality SMsgDirection where 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 @@ -281,19 +317,12 @@ instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd -toMsgDirection :: SMsgDirection d -> MsgDirection -toMsgDirection = \case - SMDRcv -> MDRcv - SMDSnd -> MDSnd - -instance ToField MsgDirection where toField = toField . msgDirectionInt - msgDirectionInt :: MsgDirection -> Int msgDirectionInt = \case MDRcv -> 0 MDSnd -> 1 -msgDirectionIntP :: Int -> Maybe MsgDirection +msgDirectionIntP :: Int64 -> Maybe MsgDirection msgDirectionIntP = \case 0 -> Just MDRcv 1 -> Just MDSnd @@ -322,6 +351,8 @@ data MsgMetaJSON = MsgMetaJSON instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +-- instance FromJson MsgMetaJSON where fromEncoding = J.genericFromEncoding JSONKeyOptions + msgMetaToJson :: MsgMeta -> MsgMetaJSON msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = MsgMetaJSON diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 6711a41dd4..29823e02c0 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -105,6 +105,7 @@ module Simplex.Chat.Store deletePendingGroupMessage, createNewChatItem, getChatPreviews, + getDirectChat, ) where @@ -124,7 +125,7 @@ import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy) -import Data.Maybe (fromJust, isJust, listToMaybe) +import Data.Maybe (listToMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) @@ -1809,8 +1810,8 @@ 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 => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId -createNewChatItem st userId chatDirection NewChatItem {createdByMsgId_, itemSent, itemTs, itemContent, itemText, createdAt} = +createNewChatItem :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId +createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, itemTs, itemContent, itemText, createdAt} = liftIO . withTransaction st $ \db -> do let (contactId_, groupId_, groupMemberId_) = ids DB.execute @@ -1822,11 +1823,13 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId_, itemSent ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, contactId_, groupId_, groupMemberId_) - :. (createdByMsgId_, itemSent, itemTs, itemContent, itemText, createdAt, createdAt) + :. (createdByMsgId, itemSent, itemTs, itemContent, itemText, createdAt, createdAt) ) ciId <- insertedRowId db - when (isJust createdByMsgId_) $ - DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id) VALUES (?,?)" (ciId, fromJust createdByMsgId_) + case createdByMsgId of + Nothing -> pure () + Just msgId -> + DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id) VALUES (?,?)" (ciId, msgId) pure ciId where ids :: (Maybe Int64, Maybe Int64, Maybe Int64) @@ -1898,15 +1901,63 @@ getGroupChatPreviews_ db User {userId, userContactId} = groupInfo = GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} in AChatPreview SCTGroup (GroupChat groupInfo) Nothing --- getDirectChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList --- getDirectChatItemList st userId contactId = --- liftIO . withTransaction st $ \db -> --- DB.query --- db --- [sql| --- ... --- |] --- (userId, contactId) +getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Chat 'CTDirect) +getDirectChat st user contactId = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + contact <- getContact_' db user contactId + chatItems <- liftIO $ getDirectChatItems_ db user contactId + pure $ Chat (DirectChat contact) chatItems + +getContact_' :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact +getContact_' db User {userId} contactId = + ExceptT $ + toContact + <$> DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, + -- Contact {profile} + cp.display_name, cp.full_name, + -- Contact {activeConn} + 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 + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + |] + (userId, contactId) + where + toContact :: [ContactRow] -> Either StoreError Contact + toContact (contactRow : _) = Right $ toContact' contactRow + toContact _ = Left $ SEContactNotFoundById contactId + +getDirectChatItems_ :: DB.Connection -> User -> Int64 -> IO [CChatItem 'CTDirect] +getDirectChatItems_ db User {userId} contactId = do + chatItems_ <- + liftIO $ + DB.query + db + [sql| + SELECT + -- CChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at + FROM chat_items ci + LEFT JOIN messages m ON m.message_id == ci.created_by_msg_id + LEFT JOIN msg_deliveries md ON md.message_id = m.message_id + WHERE ci.user_id = ? AND ci.contact_id = ? + |] + (userId, contactId) + liftIO $ mapM toDirectChatItem chatItems_ + where + toDirectChatItem :: (Int64, ChatItemTs, ACIContent, Text, UTCTime) -> IO (CChatItem 'CTDirect) + toDirectChatItem (itemId, itemTs, itemContent, itemText, createdAt) = do + ciMeta <- liftIO $ mkCIMetaProps itemId itemTs itemText createdAt + pure $ case itemContent of + ACIContent SMDRcv ciContent -> CChatItem SMDRcv (DirectChatItem (CIRcvMeta ciMeta) ciContent) + ACIContent SMDSnd ciContent -> CChatItem SMDSnd (DirectChatItem (CISndMeta ciMeta) ciContent) -- getGroupChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList -- getGroupChatItemList st userId groupId = @@ -1985,6 +2036,7 @@ randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGe data StoreError = SEDuplicateName + | SEContactNotFoundById Int64 | SEContactNotFound {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 507a51f803..6d027ed45b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -121,9 +121,9 @@ viewChatItem chat item = case (chat, item) of CISndMeta meta -> case content of CIMsgContent mc -> viewSentMessage to mc meta CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta - CIRcvMeta meta mOk -> case content of - CIMsgContent mc -> viewReceivedMessage from meta mc mOk - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft mOk + CIRcvMeta meta -> case content of + CIMsgContent mc -> viewReceivedMessage from meta mc -- mOk + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk where to = ttyToContact' c from = ttyFromContact' c @@ -132,9 +132,9 @@ viewChatItem chat item = case (chat, item) of CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToGroup g - (GroupChat g, RcvGroupChatItem c (CIRcvMeta meta mOk) content) -> case content of - CIMsgContent mc -> viewReceivedMessage from meta mc mOk - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft mOk + (GroupChat g, RcvGroupChatItem c (CIRcvMeta meta) content) -> case content of + CIMsgContent mc -> viewReceivedMessage from meta mc -- mOk + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk where from = ttyFromGroup' g c where @@ -289,12 +289,12 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> CIMetaProps -> MsgContent -> MsgIntegrity -> [StyledString] +viewReceivedMessage :: StyledString -> CIMetaProps -> MsgContent -> [StyledString] viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) -receivedWithTime_ :: StyledString -> CIMetaProps -> [StyledString] -> MsgIntegrity -> [StyledString] -receivedWithTime_ from CIMetaProps {localItemTs, createdAt} styledMsg mOk = do - prependFirst (formattedTime <> " " <> from) styledMsg ++ showIntegrity mOk +receivedWithTime_ :: StyledString -> CIMetaProps -> [StyledString] -> [StyledString] +receivedWithTime_ from CIMetaProps {localItemTs, createdAt} styledMsg = do + prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk where formattedTime :: StyledString formattedTime = @@ -363,7 +363,7 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -viewReceivedFileInvitation :: StyledString -> CIMetaProps -> RcvFileTransfer -> MsgIntegrity -> [StyledString] +viewReceivedFileInvitation :: StyledString -> CIMetaProps -> RcvFileTransfer -> [StyledString] viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] From c3a8ae1eb53b28f0032f87daac2001faaf550625 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 28 Jan 2022 10:41:09 +0000 Subject: [PATCH 18/82] chats API for mobile (#230) Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 44 +++++---- src/Simplex/Chat/Controller.hs | 11 ++- src/Simplex/Chat/Messages.hs | 174 ++++++++++++++++++--------------- src/Simplex/Chat/Store.hs | 47 +++++---- src/Simplex/Chat/View.hs | 48 ++++----- 5 files changed, 177 insertions(+), 147 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 894d142925..4f9aff7650 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -36,6 +36,7 @@ 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.Messages @@ -123,6 +124,11 @@ toView event = do processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResponse processChatCommand user@User {userId, profile} = \case + APIGetChats -> CRApiChats <$> withStore (`getChatPreviews` user) + APIGetChat cType cId -> case cType of + CTDirect -> CRApiDirectChat <$> withStore (\st -> getDirectChat st user cId) + CTGroup -> pure $ CRChatError ChatErrorNotImplemented + APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented ChatHelp section -> pure $ CRChatHelp section Welcome -> pure $ CRWelcome user AddContact -> procCmd $ do @@ -179,7 +185,7 @@ processChatCommand user@User {userId, profile} = \case SendMessage cName msg -> do contact <- withStore $ \st -> getContact st userId cName let mc = MCText $ safeDecodeUtf8 msg - ci <- sendDirectChatItem userId contact (XMsgNew mc) (CIMsgContent mc) + ci <- sendDirectChatItem userId contact (XMsgNew mc) (CISndMsgContent mc) setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci NewGroup gProfile -> do @@ -257,7 +263,7 @@ processChatCommand user@User {userId, profile} = \case group@(Group gInfo@GroupInfo {membership} _) <- withStore $ \st -> getGroup st user gName unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved let mc = MCText $ safeDecodeUtf8 msg - ci <- sendGroupChatItem userId group (XMsgNew mc) (CIMsgContent mc) + ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) setActive $ ActiveG gName pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci SendFile cName f -> do @@ -288,9 +294,9 @@ processChatCommand user@User {userId, profile} = \case let ciContent = CISndFileInvitation fileId f createdAt <- liftIO getCurrentTime let ci = mkNewChatItem ciContent 0 createdAt createdAt - ciMeta@CIMetaProps {itemId} <- saveChatItem userId (CDSndGroup gInfo) ci + ciMeta@CIMeta {itemId} <- saveChatItem userId (CDGroupSnd gInfo) ci withStore $ \st -> updateFileTransferChatItemId st fileId itemId - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ SndGroupChatItem (CISndMeta ciMeta) ciContent + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ ChatItem CIGroupSnd ciMeta ciContent ReceiveFile fileId filePath_ -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName @@ -804,14 +810,14 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do newContentMessage :: Contact -> MsgContent -> MessageId -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c} mc msgId msgMeta = do - ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIMsgContent mc) + ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIRcvMsgContent mc) toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci showToast (c <> "> ") $ msgContentText mc setActive $ ActiveC c newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContent -> MessageId -> MsgMeta -> m () newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msgId msgMeta = do - ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIMsgContent mc) + ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvMsgContent mc) toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc @@ -1163,32 +1169,33 @@ sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CICont sendDirectChatItem userId contact@Contact {activeConn} chatMsgEvent ciContent = do msgId <- sendDirectMessage activeConn chatMsgEvent createdAt <- liftIO getCurrentTime - ciMeta <- saveChatItem userId (CDDirect contact) $ mkNewChatItem ciContent msgId createdAt createdAt - pure $ DirectChatItem (CISndMeta ciMeta) ciContent + 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 (CDSndGroup g) $ mkNewChatItem ciContent msgId createdAt createdAt - pure $ SndGroupChatItem (CISndMeta ciMeta) ciContent + 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 (CDDirect ct) $ mkNewChatItem ciContent msgId brokerTs createdAt - pure $ DirectChatItem (CIRcvMeta ciMeta) ciContent + 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 (CDRcvGroup g m) $ mkNewChatItem ciContent msgId brokerTs createdAt - pure $ RcvGroupChatItem m (CIRcvMeta ciMeta) ciContent + ciMeta <- saveChatItem userId (CDGroupRcv g m) $ mkNewChatItem ciContent msgId brokerTs createdAt + pure $ ChatItem (CIGroupRcv m) ciMeta ciContent -saveChatItem :: (MsgDirectionI d, ChatMonad m) => UserId -> ChatDirection c d -> NewChatItem d -> m CIMetaProps +saveChatItem :: ChatMonad m => UserId -> ChatDirection c d -> NewChatItem d -> m CIMeta saveChatItem userId cd ci@NewChatItem {itemTs, itemText, createdAt} = do + tz <- liftIO getCurrentTimeZone ciId <- withStore $ \st -> createNewChatItem st userId cd ci - liftIO $ mkCIMetaProps ciId itemTs itemText createdAt + pure $ mkCIMeta ciId itemText tz itemTs createdAt mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d mkNewChatItem itemContent msgId itemTs createdAt = @@ -1289,7 +1296,10 @@ withStore action = chatCommandP :: Parser ChatCommand chatCommandP = - ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles + "/api/v1/chats" $> APIGetChats + <|> "/api/v1/chat/" *> (APIGetChat <$> ("direct/" $> CTDirect <|> "group/" $> CTGroup) <*> A.decimal) + <|> "/api/v1/chat/items?count=" *> (APIGetChatItems <$> 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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fbdbae3054..7ef963d598 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,4 +1,5 @@ {-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -77,7 +78,10 @@ instance ToJSON HelpSection where toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" data ChatCommand - = ChatHelp HelpSection + = APIGetChats + | APIGetChat ChatType Int64 + | APIGetChatItems Int + | ChatHelp HelpSection | Welcome | AddContact | Connect (Maybe AConnectionRequestUri) @@ -112,7 +116,9 @@ data ChatCommand deriving (Show) data ChatResponse - = CRNewChatItem {chatItem :: AChatItem} + = CRApiChats {chats :: [AChatPreview]} + | CRApiDirectChat {chat :: Chat 'CTDirect} + | CRNewChatItem {chatItem :: AChatItem} | CRCmdAccepted {corr :: CorrId} | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} @@ -190,6 +196,7 @@ data ChatError | ChatErrorMessage {errorMessage :: String} | ChatErrorAgent {agentError :: AgentErrorType} | ChatErrorStore {storeError :: StoreError} + | ChatErrorNotImplemented deriving (Show, Exception, Generic) instance ToJSON ChatError where diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 4d5c4d9344..b0cda969eb 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -22,7 +22,7 @@ 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 (ZonedTime, utcToLocalZonedTime) +import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) @@ -38,7 +38,11 @@ import Simplex.Messaging.Parsers (dropPrefix) import Simplex.Messaging.Protocol (MsgBody) data ChatType = CTDirect | CTGroup - deriving (Show) + 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 @@ -64,52 +68,63 @@ jsonChatInfo = \case DirectChat c -> JCInfoDirect c GroupChat g -> JCInfoGroup g -type ChatItemData d = (CIMeta d, CIContent d) +data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem + { chatDir :: CIDirection c d, + meta :: CIMeta, + content :: CIContent d + } + deriving (Show, Generic) -data ChatItem (c :: ChatType) (d :: MsgDirection) where - DirectChatItem :: CIMeta d -> CIContent d -> ChatItem 'CTDirect d - SndGroupChatItem :: CIMeta 'MDSnd -> CIContent 'MDSnd -> ChatItem 'CTGroup 'MDSnd - RcvGroupChatItem :: GroupMember -> CIMeta 'MDRcv -> CIContent 'MDRcv -> ChatItem 'CTGroup 'MDRcv +instance ToJSON (ChatItem c d) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions -deriving instance Show (ChatItem c d) +data CIDirection (c :: ChatType) (d :: MsgDirection) where + CIDirectSnd :: CIDirection 'CTDirect 'MDSnd + CIDirectRcv :: CIDirection 'CTDirect 'MDRcv + CIGroupSnd :: CIDirection 'CTGroup 'MDSnd + CIGroupRcv :: GroupMember -> CIDirection 'CTGroup 'MDRcv -data JSONChatItem d - = JCItemDirect {dir :: MsgDirection, meta :: CIMeta d, content :: CIContent d} - | JCItemSndGroup {dir :: MsgDirection, meta :: CIMeta d, content :: CIContent d} - | JCItemRcvGroup {dir :: MsgDirection, member :: GroupMember, meta :: CIMeta d, content :: CIContent d} +deriving instance Show (CIDirection c d) + +data JSONCIDirection + = JCIDirectSnd + | JCIDirectRcv + | JCIGroupSnd + | JCIGroupRcv {groupMember :: GroupMember} deriving (Generic) -instance MsgDirectionI d => ToJSON (JSONChatItem d) where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCItem" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCItem" +instance ToJSON JSONCIDirection where + toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" -instance MsgDirectionI d => ToJSON (ChatItem c d) where - toJSON = J.toJSON . jsonChatItem - toEncoding = J.toEncoding . jsonChatItem +instance ToJSON (CIDirection c d) where + toJSON = J.toJSON . jsonCIDirection + toEncoding = J.toEncoding . jsonCIDirection -jsonChatItem :: forall c d. MsgDirectionI d => ChatItem c d -> JSONChatItem d -jsonChatItem = \case - DirectChatItem meta cic -> JCItemDirect md meta cic - SndGroupChatItem meta cic -> JCItemSndGroup md meta cic - RcvGroupChatItem m meta cic -> JCItemRcvGroup md m meta cic - where - md = toMsgDirection $ msgDirection @d +jsonCIDirection :: CIDirection c d -> JSONCIDirection +jsonCIDirection = \case + CIDirectSnd -> JCIDirectSnd + CIDirectRcv -> JCIDirectRcv + CIGroupSnd -> JCIGroupSnd + CIGroupRcv m -> JCIGroupRcv m data CChatItem c = forall 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 = \case - DirectChatItem (CISndMeta CIMetaProps {itemId}) _ -> itemId - DirectChatItem (CIRcvMeta CIMetaProps {itemId}) _ -> itemId - SndGroupChatItem (CISndMeta CIMetaProps {itemId}) _ -> itemId - RcvGroupChatItem _ (CIRcvMeta CIMetaProps {itemId}) _ -> itemId +chatItemId ChatItem {meta = CIMeta {itemId}} = itemId data ChatDirection (c :: ChatType) (d :: MsgDirection) where - CDDirect :: Contact -> ChatDirection 'CTDirect d - CDSndGroup :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd - CDRcvGroup :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + 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, @@ -122,16 +137,38 @@ data NewChatItem d = NewChatItem deriving (Show) -- | type to show one chat with messages -data Chat c = Chat (ChatInfo c) [CChatItem c] - deriving (Show) +data Chat c = Chat {chatInfo :: ChatInfo c, chatItems :: [CChatItem c]} + deriving (Show, Generic) + +instance ToJSON (Chat c) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +data ChatPreview c = ChatPreview {chatInfo :: ChatInfo c, lastChatItem :: Maybe (CChatItem c)} + deriving (Show, Generic) + +instance ToJSON (ChatPreview c) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions -- | type to show the list of chats, with one last message in each data AChatPreview = forall c. AChatPreview (SChatType c) (ChatInfo c) (Maybe (CChatItem c)) deriving instance Show AChatPreview +instance ToJSON AChatPreview where + toJSON (AChatPreview _ chat ccItem_) = J.toJSON $ JSONAnyChatPreview chat ccItem_ + toEncoding (AChatPreview _ chat ccItem_) = J.toEncoding $ J.toJSON $ JSONAnyChatPreview chat ccItem_ + +data JSONAnyChatPreview c d = JSONAnyChatPreview {chatInfo :: ChatInfo c, chatItem :: Maybe (CChatItem c)} + deriving (Generic) + +instance ToJSON (JSONAnyChatPreview c d) 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. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) +data AChatItem = forall c d. AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem @@ -142,35 +179,11 @@ instance ToJSON AChatItem where data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} deriving (Generic) -instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where +instance ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions -data CIMeta (d :: MsgDirection) where - CISndMeta :: CIMetaProps -> CIMeta 'MDSnd - CIRcvMeta :: CIMetaProps -> CIMeta 'MDRcv - -deriving instance Show (CIMeta d) - -instance ToJSON (CIMeta d) where - toJSON = J.toJSON . jsonCIMeta - toEncoding = J.toEncoding . jsonCIMeta - -data JSONCIMeta - = JCIMetaSnd {meta :: CIMetaProps} - | JCIMetaRcv {meta :: CIMetaProps} - deriving (Generic) - -instance ToJSON JSONCIMeta where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCIMeta" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCIMeta" - -jsonCIMeta :: CIMeta d -> JSONCIMeta -jsonCIMeta = \case - CISndMeta meta -> JCIMetaSnd meta - CIRcvMeta meta -> JCIMetaRcv meta - -data CIMetaProps = CIMetaProps +data CIMeta = CIMeta { itemId :: ChatItemId, itemTs :: ChatItemTs, itemText :: Text, @@ -179,19 +192,20 @@ data CIMetaProps = CIMetaProps } deriving (Show, Generic, FromJSON) -mkCIMetaProps :: ChatItemId -> ChatItemTs -> Text -> UTCTime -> IO CIMetaProps -mkCIMetaProps itemId itemTs itemText createdAt = do - localItemTs <- utcToLocalZonedTime itemTs - pure CIMetaProps {itemId, itemTs, itemText, localItemTs, createdAt} +mkCIMeta :: ChatItemId -> Text -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta +mkCIMeta itemId itemText tz itemTs createdAt = + let localItemTs = utcToZonedTime tz itemTs + in CIMeta {itemId, itemTs, itemText, localItemTs, createdAt} -instance ToJSON CIMetaProps where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON CIMeta where toEncoding = J.genericToEncoding J.defaultOptions type ChatItemId = Int64 type ChatItemTs = UTCTime data CIContent (d :: MsgDirection) where - CIMsgContent :: MsgContent -> CIContent d + CISndMsgContent :: MsgContent -> CIContent 'MDSnd + CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv @@ -199,14 +213,15 @@ deriving instance Show (CIContent d) ciContentToText :: CIContent d -> Text ciContentToText = \case - CIMsgContent mc -> msgContentText mc + 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 MsgDirectionI d => ToField (CIContent d) where +instance ToField (CIContent d) where toField = toField . decodeLatin1 . LB.toStrict . J.encode -instance MsgDirectionI d => ToJSON (CIContent d) where +instance ToJSON (CIContent d) where toJSON = J.toJSON . jsonCIContent toEncoding = J.toEncoding . jsonCIContent @@ -218,7 +233,8 @@ instance FromJSON ACIContent where instance FromField ACIContent where fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 data JSONCIContent - = JCIMsgContent {msgDir :: MsgDirection, msgContent :: MsgContent} + = JCISndMsgContent {msgContent :: MsgContent} + | JCIRcvMsgContent {msgContent :: MsgContent} | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) @@ -230,19 +246,17 @@ instance ToJSON JSONCIContent where toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" -jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent +jsonCIContent :: CIContent d -> JSONCIContent jsonCIContent = \case - CIMsgContent mc -> JCIMsgContent md mc + CISndMsgContent mc -> JCISndMsgContent mc + CIRcvMsgContent mc -> JCIRcvMsgContent mc CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath CIRcvFileInvitation ft -> JCIRcvFileInvitation ft - where - md = toMsgDirection $ msgDirection @d aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case - JCIMsgContent md mc -> case md of - MDSnd -> ACIContent SMDSnd $ CIMsgContent mc - MDRcv -> ACIContent SMDRcv $ CIMsgContent mc + 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 @@ -351,8 +365,6 @@ data MsgMetaJSON = MsgMetaJSON instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} --- instance FromJson MsgMetaJSON where fromEncoding = J.genericFromEncoding JSONKeyOptions - msgMetaToJson :: MsgMeta -> MsgMetaJSON msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = MsgMetaJSON diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 29823e02c0..af13503d2a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -129,6 +129,7 @@ import Data.Maybe (listToMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) @@ -1810,7 +1811,7 @@ 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 :: (MsgDirectionI d, MonadUnliftIO m) => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId +createNewChatItem :: MonadUnliftIO m => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, itemTs, itemContent, itemText, createdAt} = liftIO . withTransaction st $ \db -> do let (contactId_, groupId_, groupMemberId_) = ids @@ -1834,9 +1835,10 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, where ids :: (Maybe Int64, Maybe Int64, Maybe Int64) ids = case chatDirection of - CDDirect Contact {contactId} -> (Just contactId, Nothing, Nothing) - CDSndGroup GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) - CDRcvGroup GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId) + 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 [AChatPreview] getChatPreviews st user = @@ -1936,28 +1938,23 @@ getContact_' db User {userId} contactId = getDirectChatItems_ :: DB.Connection -> User -> Int64 -> IO [CChatItem 'CTDirect] getDirectChatItems_ db User {userId} contactId = do - chatItems_ <- - liftIO $ - DB.query - db - [sql| - SELECT - -- CChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at - FROM chat_items ci - LEFT JOIN messages m ON m.message_id == ci.created_by_msg_id - LEFT JOIN msg_deliveries md ON md.message_id = m.message_id - WHERE ci.user_id = ? AND ci.contact_id = ? - |] - (userId, contactId) - liftIO $ mapM toDirectChatItem chatItems_ + tz <- getCurrentTimeZone + map (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT chat_item_id, item_ts, item_content, item_text, created_at + FROM chat_items + WHERE user_id = ? AND contact_id = ? + |] + (userId, contactId) where - toDirectChatItem :: (Int64, ChatItemTs, ACIContent, Text, UTCTime) -> IO (CChatItem 'CTDirect) - toDirectChatItem (itemId, itemTs, itemContent, itemText, createdAt) = do - ciMeta <- liftIO $ mkCIMetaProps itemId itemTs itemText createdAt - pure $ case itemContent of - ACIContent SMDRcv ciContent -> CChatItem SMDRcv (DirectChatItem (CIRcvMeta ciMeta) ciContent) - ACIContent SMDSnd ciContent -> CChatItem SMDSnd (DirectChatItem (CISndMeta ciMeta) ciContent) + toDirectChatItem :: TimeZone -> (Int64, ChatItemTs, ACIContent, Text, UTCTime) -> (CChatItem 'CTDirect) + toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) = + let ciMeta = mkCIMeta itemId itemText tz itemTs createdAt + in case itemContent of + ACIContent d@SMDSnd ciContent -> CChatItem d $ ChatItem CIDirectSnd ciMeta ciContent + ACIContent d@SMDRcv ciContent -> CChatItem d $ ChatItem CIDirectRcv ciMeta ciContent -- getGroupChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList -- getGroupChatItemList st userId groupId = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6d027ed45b..225715e04a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} @@ -33,6 +34,8 @@ serializeChatResponse = unlines . map unStyle . responseToView "" responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case + CRApiChats chats -> [sShow chats] + CRApiDirectChat chat -> [sShow chat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRCmdAccepted _ -> r [] CRChatHelp section -> case section of @@ -116,31 +119,31 @@ responseToView cmd = \case r' = r viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] -viewChatItem chat item = case (chat, item) of - (DirectChat c, DirectChatItem ciMeta content) -> case ciMeta of - CISndMeta meta -> case content of - CIMsgContent mc -> viewSentMessage to mc meta - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta - CIRcvMeta meta -> case content of - CIMsgContent mc -> viewReceivedMessage from meta mc -- mOk - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk +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, SndGroupChatItem (CISndMeta meta) content) -> case content of - CIMsgContent mc -> viewSentMessage to mc meta + (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, RcvGroupChatItem c (CIRcvMeta meta) content) -> case content of - CIMsgContent mc -> viewReceivedMessage from meta mc -- mOk + (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 c + from = ttyFromGroup' g m where ttyToContact' Contact {localDisplayName = c} = ttyToContact c ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c - ttyFromGroup' g GroupMember {localDisplayName = c} = ttyFromGroup g c + ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m viewInvalidConnReq :: [StyledString] viewInvalidConnReq = @@ -289,11 +292,11 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> CIMetaProps -> MsgContent -> [StyledString] +viewReceivedMessage :: StyledString -> CIMeta -> MsgContent -> [StyledString] viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) -receivedWithTime_ :: StyledString -> CIMetaProps -> [StyledString] -> [StyledString] -receivedWithTime_ from CIMetaProps {localItemTs, createdAt} styledMsg = do +receivedWithTime_ :: StyledString -> CIMeta -> [StyledString] -> [StyledString] +receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk where formattedTime :: StyledString @@ -318,14 +321,14 @@ receivedWithTime_ from CIMetaProps {localItemTs, createdAt} styledMsg = do msgError :: String -> [StyledString] msgError s = [styled (Colored Red) s] -viewSentMessage :: StyledString -> MsgContent -> CIMetaProps -> [StyledString] +viewSentMessage :: StyledString -> MsgContent -> CIMeta -> [StyledString] viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent -viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMetaProps -> [StyledString] +viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta -> [StyledString] viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath -sentWithTime_ :: [StyledString] -> CIMetaProps -> [StyledString] -sentWithTime_ styledMsg CIMetaProps {localItemTs} = +sentWithTime_ :: [StyledString] -> CIMeta -> [StyledString] +sentWithTime_ styledMsg CIMeta {localItemTs} = prependFirst (ttyMsgTime localItemTs <> " ") styledMsg ttyMsgTime :: ZonedTime -> StyledString @@ -363,7 +366,7 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -viewReceivedFileInvitation :: StyledString -> CIMetaProps -> RcvFileTransfer -> [StyledString] +viewReceivedFileInvitation :: StyledString -> CIMeta -> RcvFileTransfer -> [StyledString] viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] @@ -481,6 +484,7 @@ viewChatError = \case SMP SMP.AUTH -> ["error: this connection is deleted"] e -> ["smp agent error: " <> sShow e] ChatErrorMessage e -> ["chat message error: " <> sShow e] + ChatErrorNotImplemented -> ["chat error: not implemented"] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] From 55dde3531e4e6f89501e026702262ee7cc12d3dc Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 28 Jan 2022 19:24:31 +0400 Subject: [PATCH 19/82] most recent chat items in getDirectChatPreviews_ (#232) --- src/Simplex/Chat/Store.hs | 53 ++++++++++++++++++++++++++++----------- src/Simplex/Chat/View.hs | 5 ++-- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index af13503d2a..a5e1fc1238 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1848,8 +1848,9 @@ getChatPreviews st user = pure $ directChatPreviews <> groupChatPreviews getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] -getDirectChatPreviews_ db User {userId} = - map toDirectChatPreview +getDirectChatPreviews_ db User {userId} = do + tz <- getCurrentTimeZone + map (toDirectChatPreview tz) <$> DB.query db [sql| @@ -1860,18 +1861,30 @@ getDirectChatPreviews_ db User {userId} = cp.display_name, cp.full_name, -- Contact {activeConn} 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 + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, + -- CChatItem 'CTDirect + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, 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(item_ts) MaxDate + FROM chat_items + WHERE item_deleted != 1 + GROUP BY contact_id + ) CIMaxDates ON CIMaxDates.contact_id = c.contact_id + LEFT JOIN chat_items ci ON ci.contact_id == CIMaxDates.contact_id + AND ci.item_ts == CIMaxDates.MaxDate WHERE ct.user_id = ? + ORDER BY ci.item_ts ASC |] (Only userId) where - toDirectChatPreview :: ContactRow -> AChatPreview - toDirectChatPreview contactRow = + toDirectChatPreview :: TimeZone -> ContactRow :. MaybeChatItemRow -> AChatPreview + toDirectChatPreview tz (contactRow :. ciRow_) = let contact = toContact' contactRow - in AChatPreview SCTDirect (DirectChat contact) Nothing + ci_ = toMaybeDirectChatItem tz ciRow_ + in AChatPreview SCTDirect (DirectChat contact) ci_ getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] getGroupChatPreviews_ db User {userId, userContactId} = @@ -1893,9 +1906,9 @@ getGroupChatPreviews_ db User {userId, userContactId} = JOIN group_profiles gp ON gp.group_profile_id == g.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 g.user_id = ? + WHERE g.user_id = ? AND mu.contact_id = ? |] - (Only userId) + (userId, userContactId) where toGroupChatPreview :: (Int64, GroupName, GroupName, Text) :. GroupMemberRow -> AChatPreview toGroupChatPreview ((groupId, localDisplayName, displayName, fullName) :. userMemberRow) = @@ -1946,15 +1959,25 @@ getDirectChatItems_ db User {userId} contactId = do SELECT chat_item_id, item_ts, item_content, item_text, created_at FROM chat_items WHERE user_id = ? AND contact_id = ? + ORDER BY item_ts ASC |] (userId, contactId) - where - toDirectChatItem :: TimeZone -> (Int64, ChatItemTs, ACIContent, Text, UTCTime) -> (CChatItem 'CTDirect) - toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) = - let ciMeta = mkCIMeta itemId itemText tz itemTs createdAt - in case itemContent of - ACIContent d@SMDSnd ciContent -> CChatItem d $ ChatItem CIDirectSnd ciMeta ciContent - ACIContent d@SMDRcv ciContent -> CChatItem d $ ChatItem CIDirectRcv ciMeta ciContent + +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, UTCTime) + +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe UTCTime) + +toDirectChatItem :: TimeZone -> ChatItemRow -> CChatItem 'CTDirect +toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) = + let ciMeta = mkCIMeta itemId itemText tz itemTs createdAt + in case itemContent of + ACIContent d@SMDSnd ciContent -> CChatItem d $ ChatItem CIDirectSnd ciMeta ciContent + ACIContent d@SMDRcv ciContent -> CChatItem d $ ChatItem CIDirectRcv ciMeta ciContent + +toMaybeDirectChatItem :: TimeZone -> MaybeChatItemRow -> Maybe (CChatItem 'CTDirect) +toMaybeDirectChatItem tz (Just itemId, Just itemTs, Just itemContent, Just itemText, Just createdAt) = + Just $ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) +toMaybeDirectChatItem _ _ = Nothing -- getGroupChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList -- getGroupChatItemList st userId groupId = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 225715e04a..9923c3a39d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -34,8 +34,8 @@ serializeChatResponse = unlines . map unStyle . responseToView "" responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case - CRApiChats chats -> [sShow chats] - CRApiDirectChat chat -> [sShow chat] + CRApiChats chats -> api [sShow chats] + CRApiDirectChat chat -> api [sShow chat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRCmdAccepted _ -> r [] CRChatHelp section -> case section of @@ -114,6 +114,7 @@ responseToView cmd = \case CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e where + api = (highlight cmd :) r = (plain cmd :) -- this function should be `id` in case of asynchronous command responses r' = r From 7c36ee79554ab6e6e6ea3fcd50bd42c3dc77e805 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 29 Jan 2022 11:10:04 +0000 Subject: [PATCH 20/82] swift API for chat, started chat UI (#228) * started swift API for chat * skeleton UI * show all chat responses in Terminal view * show chat list in UI * refactor swift API --- apps/ios/Shared/ContentView.swift | 95 ++++---- apps/ios/Shared/Model/ChatModel.swift | 147 ++++++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 220 ++++++++++++++++++ .../MyPlayground.playground/Contents.swift | 54 +++++ .../contents.xcplayground | 4 + .../timeline.xctimeline | 11 + .../Shared/SimpleX (iOS)-Bridging-Header.h | 12 +- .../Shared/SimpleX (macOS)-Bridging-Header.h | 8 +- apps/ios/Shared/SimpleXApp.swift | 37 +-- apps/ios/Shared/Views/ChatListView.swift | 56 +++++ apps/ios/Shared/Views/ChatPreviewView.swift | 33 +++ apps/ios/Shared/Views/ChatView.swift | 37 +++ apps/ios/Shared/{ => Views}/MessageView.swift | 0 apps/ios/Shared/Views/TerminalView.swift | 73 ++++++ apps/ios/Shared/Views/UserView.swift | 83 +++++++ .../WelcomeView.swift} | 20 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 54 ++++- 17 files changed, 845 insertions(+), 99 deletions(-) create mode 100644 apps/ios/Shared/Model/SimpleXAPI.swift create mode 100644 apps/ios/Shared/MyPlayground.playground/Contents.swift create mode 100644 apps/ios/Shared/MyPlayground.playground/contents.xcplayground create mode 100644 apps/ios/Shared/MyPlayground.playground/timeline.xctimeline create mode 100644 apps/ios/Shared/Views/ChatListView.swift create mode 100644 apps/ios/Shared/Views/ChatPreviewView.swift create mode 100644 apps/ios/Shared/Views/ChatView.swift rename apps/ios/Shared/{ => Views}/MessageView.swift (100%) create mode 100644 apps/ios/Shared/Views/TerminalView.swift create mode 100644 apps/ios/Shared/Views/UserView.swift rename apps/ios/Shared/{ProfileView.swift => Views/WelcomeView.swift} (59%) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index bf89e2c171..2cceaba497 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -46,62 +46,59 @@ import SwiftUI struct ContentView: View { - - var controller: controller + @EnvironmentObject var chatModel: ChatModel - init(controller: controller) { - self.controller = controller - } +// var chatStore: chat_store +// private let controller: chat_controller + +// init(chatStore: chat_store) { +// self.chatStore = chatStore +// } - @State private var logbuffer = [String]() - @State private var chatcmd: String = "" - @State private var chatlog: String = "" - @FocusState private var focused: Bool - - func addLine(line: String) { - print(line) - logbuffer.append(line) - if(logbuffer.count > 50) { _ = logbuffer.dropFirst() } - chatlog = logbuffer.joined(separator: "\n") - } +// @State private var logbuffer = [String]() +// @State private var chatcmd: String = "" +// @State private var chatlog: String = "" +// @FocusState private var focused: Bool +// +// func addLine(line: String) { +// print(line) +// logbuffer.append(line) +// if(logbuffer.count > 50) { _ = logbuffer.dropFirst() } +// chatlog = logbuffer.joined(separator: "\n") +// } var body: some View { - - DispatchQueue.global().async { - while(true) { - let msg = String.init(cString: chat_recv_msg(controller)) - - DispatchQueue.main.async { - addLine(line: msg) - } - } + if let user = chatModel.currentUser { + ChatListView(user: user) + .onAppear { chatSendCmd(chatModel, .apiGetChats) } + } else { + WelcomeView() } - - return VStack { - ScrollView { - VStack(alignment: .leading) { - HStack { Spacer() } - Text(chatlog) - .lineLimit(nil) - .font(.system(.body, design: .monospaced)) - } - .frame(maxWidth: .infinity) - } - TextField("Chat command", text: $chatcmd) - .focused($focused) - .onSubmit { - print(chatcmd) - var cCmd = chatcmd.cString(using: .utf8)! - print(String.init(cString: chat_send_cmd(controller, &cCmd))) - } - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding() - } - } - +// return VStack { +// ScrollView { +// VStack(alignment: .leading) { +// HStack { Spacer() } +// Text(chatlog) +// .lineLimit(nil) +// .font(.system(.body, design: .monospaced)) +// } +// .frame(maxWidth: .infinity) +// } +// +// TextField("Chat command", text: $chatcmd) +// .focused($focused) +// .onSubmit { +// print(chatcmd) +// var cCmd = chatcmd.cString(using: .utf8)! +// print(String.init(cString: chat_send_cmd(controller, &cCmd))) +// } +// .textInputAutocapitalization(.never) +// .disableAutocorrection(true) +// .padding() +// } + } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index df83f32b4a..766ac78cc2 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -8,11 +8,13 @@ import Foundation import Combine -import SwiftUI final class ChatModel: ObservableObject { @Published var currentUser: User? - @Published var channels: [ChatChannel] = [] + @Published var chats: Dictionary = [:] + @Published var chatPreviews: [ChatPreview] = [] + @Published var chatItems: [ChatItem] = [] + @Published var apiResponses: [APIResponse] = [] } struct User: Codable { @@ -32,22 +34,76 @@ struct Profile: Codable { var fullName: String } -enum ChatChannel { - case contact(ContactInfo, [ChatMessage]) - case group(GroupInfo, [ChatMessage]) +struct ChatPreview: Identifiable, Codable { + var chatInfo: ChatInfo + var lastChatItem: ChatItem? + + var id: String { + get { chatInfo.id } + } } -struct ContactInfo: Codable { +enum ChatInfo: Identifiable, Codable { + case direct(contact: Contact) +// case group() + + var displayName: String { + get { + switch self { + case let .direct(contact): return "@\(contact.localDisplayName)" +// case let .group(groupInfo, _): return "#\(groupInfo.localDisplayName)" + } + } + } + + var id: String { + get { + switch self { + case let .direct(contact): return "@\(contact.contactId)" +// case let .group(contact): return group.id + } + } + } + + var apiType: String { + get { + switch self { + case .direct(_): return "direct" +// case let .group(_): return "group" + } + } + } + + var apiId: Int64 { + get { + switch self { + case let .direct(contact): return contact.contactId +// case let .group(contact): return group.id + } + } + } +} + +class Chat: Codable { + var chatInfo: ChatInfo + var chatItems: [ChatItem] +} + +struct Contact: Identifiable, Codable { var contactId: Int64 var localDisplayName: ContactName var profile: Profile var viaGroup: Int64? + + var id: String { get { "@\(contactId)" } } } -struct GroupInfo: Codable { +struct GroupInfo: Identifiable, Codable { var groupId: Int64 var localDisplayName: GroupName var groupProfile: GroupProfile + + var id: String { get { "#\(groupId)" } } } struct GroupProfile: Codable { @@ -55,13 +111,76 @@ struct GroupProfile: Codable { var fullName: String } -struct ChatMessage { - var from: ContactInfo? - var ts: Date - var content: MsgContent +struct GroupMember: Codable { + } -enum MsgContent { - case text(String) - case unknown +struct ChatItem: Identifiable, Codable { + var chatDir: CIDirection + var meta: CIMeta + var content: CIContent + + var id: Int64 { get { meta.itemId } } } + +enum CIDirection: Codable { + case directSnd + case directRcv + case groupSnd + case groupRcv(GroupMember) +} + +struct CIMeta: Codable { + var itemId: Int64 + var itemTs: Date + var itemText: String + var createdAt: Date +} + +enum CIContent: Codable { + case sndMsgContent(msgContent: MsgContent) + case rcvMsgContent(msgContent: MsgContent) + // files etc. + + var text: String { + get { + switch self { + case let .sndMsgContent(mc): return mc.string + case let .rcvMsgContent(mc): return mc.string + } + } + } +} + +enum MsgContent: Codable { + case text(String) + case unknown(type: String, text: String, json: String) + case invalid(json: String) + + init(from: Decoder) throws { + self = .invalid(json: "") + } + + var string: String { + get { + switch self { + case let .text(str): return str + case .unknown: return "unknown" + case .invalid: return "invalid" + } + } + } +} + +//func parseMsgContent(_ mc: SomeMsgContent) -> MsgContent { +// if let type = mc["type"] as? String { +// let text_ = mc["text"] as? String +// switch type { +// case "text": +// if let text = text_ { return .text(text) } +// case let t: +// return .unknown(type: t, text: text_ ?? "unknown item", json: prettyJSON(mc) ?? "error") +// } +// } +// return .invalid(json: prettyJSON(mc) ?? "error") +//} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift new file mode 100644 index 0000000000..b2dd350fb0 --- /dev/null +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -0,0 +1,220 @@ +// +// ChatAPI.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +private var chatStore: chat_store? +private var chatController: chat_ctrl? +private let jsonDecoder = JSONDecoder() +private let jsonEncoder = JSONEncoder() + +enum ChatCommand { + case apiGetChats + case apiGetChatItems(type: String, id: Int64) + case string(String) + case help + + var cmdString: String { + get { + switch self { + case .apiGetChats: + return "/api/v1/chats" + case let .apiGetChatItems(type, id): + return "/api/v1/chat/items/\(type)/\(id)" + case let .string(str): + return str + case .help: return "/help" + } + } + } +} + +struct APIResponse: Identifiable { + var resp: ChatResponse + var id: Int64 +} + +struct APIResponseJSON: Decodable { + var resp: ChatResponse +} + +enum ChatResponse: Codable { + case response(type: String, json: String) + case apiChats(chats: [ChatPreview]) + case apiDirectChat(chat: Chat) // direct/ or group/, same as ChatPreview.id +// case chatHelp(String) +// case newSentInvitation + case contactConnected(contact: Contact) + + var responseType: String { + get { + switch self { + case let .response(type, _): return "* \(type)" + case .apiChats(_): return "apiChats" + case .apiDirectChat(_): return "apiDirectChat" + case .contactConnected(_): return "contactConnected" + } + } + } + + var details: String { + get { + switch self { + case let .response(_, json): return json + case let .apiChats(chats): return String(describing: chats) + case let .apiDirectChat(chat): return String(describing: chat) + case let .contactConnected(contact): return String(describing: contact) + } + } + } +} + +func chatGetUser() -> User? { + let store = getStore() + print("chatGetUser") + let r: UserResponse? = decodeCJSON(chat_get_user(store)) + let user = r?.user + if user != nil { initChatCtrl(store) } + print("user", user as Any) + return user +} + +func chatCreateUser(_ p: Profile) -> User? { + let store = getStore() + print("chatCreateUser") + var str = encodeCJSON(p) + chat_create_user(store, &str) + let user = chatGetUser() + if user != nil { initChatCtrl(store) } + print("user", user as Any) + return user +} + +func chatSendCmd(_ chatModel: ChatModel, _ cmd: ChatCommand) { + var c = cmd.cmdString.cString(using: .utf8)! + processAPIResponse(chatModel, + apiResponse( + chat_send_cmd(getChatCtrl(), &c)!)) +} + +func chatRecvMsg(_ chatModel: ChatModel) { + processAPIResponse(chatModel, + apiResponse( + chat_recv_msg(getChatCtrl())!)) +} + +private func processAPIResponse(_ chatModel: ChatModel, _ res: APIResponse?) { + if let r = res { + DispatchQueue.main.async { + chatModel.apiResponses.append(r) + switch r.resp { + case let .apiChats(chats): + chatModel.chatPreviews = chats + case let .apiDirectChat(chat): + chatModel.chats[chat.chatInfo.id] = chat + case let .contactConnected(contact): + chatModel.chatPreviews.insert( + ChatPreview(chatInfo: .direct(contact: contact)), + at: 0 + ) + default: return + +// case let .response(type, _): +// chatModel.chatItems.append(ChatItem( +// ts: Date.now, +// content: .text(type) +// )) + } + } + } +} + +private struct UserResponse: Decodable { + var user: User? + var error: String? +} + +private var respId: Int64 = 0 + +private func apiResponse(_ cjson: UnsafePointer) -> APIResponse? { + let s = String.init(cString: cjson) + print("apiResponse", s) + 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) + + do { + let r = try jsonDecoder.decode(APIResponseJSON.self, from: d) + return APIResponse(resp: r.resp, id: respId) + } catch { + print (error) + } + + 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) + } + respId += 1 + return APIResponse( + resp: ChatResponse.response(type: type ?? "invalid", json: json ?? s), + id: respId + ) +} + +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 getStore() -> chat_store { + if let store = chatStore { return store } + let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" + var cstr = dataDir.cString(using: .utf8)! + chatStore = chat_init_store(&cstr) + return chatStore! +} + +private func initChatCtrl(_ store: chat_store) { + if chatController == nil { + chatController = chat_start(store) + } +} + +private func getChatCtrl() -> chat_ctrl { + if let controller = chatController { return controller } + fatalError("Chat controller was not started!") +} + +private func decodeCJSON(_ cjson: UnsafePointer) -> T? { + let s = String.init(cString: cjson) + print("decodeCJSON", s) + 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) + print("encodeCJSON", str) + return str.cString(using: .utf8)! +} diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift new file mode 100644 index 0000000000..b81ba746c3 --- /dev/null +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -0,0 +1,54 @@ +import Foundation + +var greeting = "Hello, playground" + +let jsonEncoder = JSONEncoder() + +let ct = Contact( + contactId: 123, + localDisplayName: "ep", + profile: Profile(displayName: "ep", fullName: "") +) + +//let data = try! jsonEncoder.encode(ChatResponse.contactConnected(contact: ct)) + +//print(String(decoding: data, as: UTF8.self)) + +//var str = """ +//{"resp":{"apiChats":{"chats": +//[{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":2,"profile": +//{"displayName":"simplex","fullName":""},"activeConn": +//{"connLevel":0,"entityId":2,"connType":"contact","connId":1 +//,"agentConnId":"QTRteFhTR1dWQnpQZHE3NQ==","createdAt":"2022-01-27T19:43:44.015562Z","connStatus":"ready"},"localDisplayName":"simplex"}}}}, +//{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":3,"profile": +//{"displayName":"ep","fullName":"Evgeny"},"activeConn": +//{"connLevel":0,"entityId":3,"connType":"contact","connId":2 +//,"agentConnId":"cTdFNkprSHhZZmZhdWFQVg==","createdAt":"2022-01-27T19:47:08.891646Z","connStatus":"ready"},"localDisplayName":"ep"}}}}]}}} +//""" + +//var str = """ +//[{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":2,"profile": +//{"displayName":"simplex","fullName":""},"activeConn": +//{"connLevel":0,"entityId":2,"connType":"contact","connId":1 +//,"agentConnId":"QTRteFhTR1dWQnpQZHE3NQ==","createdAt":"2022-01-27T19:43:44.015562Z","connStatus":"ready"},"localDisplayName":"simplex"}}}}, +//{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":3,"profile": +//{"displayName":"ep","fullName":"Evgeny"},"activeConn": +//{"connLevel":0,"entityId":3,"connType":"contact","connId":2 +//,"agentConnId":"cTdFNkprSHhZZmZhdWFQVg==","createdAt":"2022-01-27T19:47:08.891646Z","connStatus":"ready"},"localDisplayName":"ep"}}}}] +//""" +// +//let data = str.data(using: .utf8)! + +let jsonDecoder = JSONDecoder() + +//let r: [ChatPreview] = try! jsonDecoder.decode([ChatPreview].self, from: data) +// +//print(r) + +struct Test: Decodable { + var name: String + var id: Int64 = 0 +} + +jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!) + 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..62084a5f42 --- /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 index 4b5b9d876b..e9f8948778 100644 --- a/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h +++ b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h @@ -2,14 +2,14 @@ // 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[]); +extern void hs_init(int argc, char **argv[]); typedef void* chat_store; -typedef void* controller; +typedef void* chat_ctrl; -extern chat_store chat_init_store(char * path); +extern chat_store chat_init_store(char *path); extern char *chat_get_user(chat_store store); extern char *chat_create_user(chat_store store, char *data); -extern controller chat_start(chat_store store); -extern char *chat_send_cmd(controller ctl, char *cmd); -extern char *chat_recv_msg(controller ctl); +extern chat_ctrl chat_start(chat_store store); +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 index 4b5b9d876b..62f2ba6626 100644 --- a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h +++ b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h @@ -5,11 +5,11 @@ extern void hs_init(int argc, char ** argv[]); typedef void* chat_store; -typedef void* controller; +typedef void* chat_ctrl; extern chat_store chat_init_store(char * path); extern char *chat_get_user(chat_store store); extern char *chat_create_user(chat_store store, char *data); -extern controller chat_start(chat_store store); -extern char *chat_send_cmd(controller ctl, char *cmd); -extern char *chat_recv_msg(controller ctl); +extern chat_ctrl chat_start(chat_store store); +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 index 68fd67c2d8..5af995644a 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -9,26 +9,33 @@ import SwiftUI @main struct SimpleXApp: App { - private let controller: controller + @StateObject private var chatModel = ChatModel() +// let store: chat_store + init() { hs_init(0, nil) - - let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" - var cstr = dataDir.cString(using: .utf8)! - let store = chat_init_store(&cstr) - let user = String.init(cString: chat_get_user(store)) - print(user) - if user == "{}" { - var data = "{ \"displayName\": \"test\", \"fullName\": \"ios test\" }".cString(using: .utf8)! - chat_create_user(store, &data) - } - controller = chat_start(store) - var cmd = "/help".cString(using: .utf8)! - print(String.init(cString: chat_send_cmd(controller, &cmd))) +// let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" +// var cstr = dataDir.cString(using: .utf8)! +// store = chat_init_store(&cstr) +// let user = String.init(cString: chat_get_user(store)) +// print(user) +// if user != "{}" { +// chatModel.currentUser = parseJSON(user) +// var data = "{ \"displayName\": \"test\", \"fullName\": \"ios test\" }".cString(using: .utf8)! +// chat_create_user(store, &data) +// } +// controller = chat_start(store) +// var cmd = "/help".cString(using: .utf8)! +// print(String.init(cString: chat_send_cmd(controller, &cmd))) } + var body: some Scene { WindowGroup { - ContentView(controller: controller) + ContentView() + .environmentObject(chatModel) + .onAppear() { + chatModel.currentUser = chatGetUser() + } } } } diff --git a/apps/ios/Shared/Views/ChatListView.swift b/apps/ios/Shared/Views/ChatListView.swift new file mode 100644 index 0000000000..d77d68a06d --- /dev/null +++ b/apps/ios/Shared/Views/ChatListView.swift @@ -0,0 +1,56 @@ +// +// 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 + var user: User + + var body: some View { + DispatchQueue.global().async { + while(true) { chatRecvMsg(chatModel) } + } + + return VStack { +// if chatModel.chats.isEmpty { + VStack { + Text("Hello chat") + Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") + } +// } + NavigationView { + List { + NavigationLink { + TerminalView() + } label: { + Text("Terminal") + } + + ForEach(chatModel.chatPreviews) { chatPreview in + NavigationLink { + ChatView(chatInfo: chatPreview.chatInfo) + .onAppear { + chatSendCmd(chatModel, .apiGetChatItems(type: "direct", id: chatPreview.chatInfo.apiId)) + } + } label: { + ChatPreviewView(chatPreview: chatPreview) + } + } + } + } + .navigationViewStyle(.stack) + } + } +} + +//struct ChatListView_Previews: PreviewProvider { +// static var previews: some View { +// ChatListView() +// } +//} diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift new file mode 100644 index 0000000000..4b05988ab2 --- /dev/null +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -0,0 +1,33 @@ +// +// ChatPreviewView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 28/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatPreviewView: View { + var chatPreview: ChatPreview + + var body: some View { + Text(chatPreview.chatInfo.displayName) + } +} + +struct ChatPreviewView_Previews: PreviewProvider { + static var previews: some View { + ChatPreviewView(chatPreview: ChatPreview( + chatInfo: .direct(contact: Contact( + contactId: 123, + localDisplayName: "ep", + profile: Profile( + displayName: "ep", + fullName: "Ep" + ), + viaGroup: nil + )) + )) + } +} diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/ChatView.swift new file mode 100644 index 0000000000..eb3b89ee19 --- /dev/null +++ b/apps/ios/Shared/Views/ChatView.swift @@ -0,0 +1,37 @@ +// +// ChatView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatView: View { + @EnvironmentObject var chatModel: ChatModel + var chatInfo: ChatInfo + var body: some View { + VStack { + if let chat: Chat = chatModel.chats[chatInfo.id] { + VStack { + ScrollView { + LazyVStack { + ForEach(chat.chatItems) { chatItem in + Text(chatItem.content.text) + } + } + } + } + } else { + Text("unexpected: chat not found...") + } + } + } +} + +//struct ChatView_Previews: PreviewProvider { +// static var previews: some View { +// ChatView() +// } +//} diff --git a/apps/ios/Shared/MessageView.swift b/apps/ios/Shared/Views/MessageView.swift similarity index 100% rename from apps/ios/Shared/MessageView.swift rename to apps/ios/Shared/Views/MessageView.swift diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift new file mode 100644 index 0000000000..11521fb680 --- /dev/null +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -0,0 +1,73 @@ +// +// 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 command: String = "" + @State var inProgress: Bool = false + + var body: some View { + VStack { + ScrollView { + LazyVStack { + ForEach(chatModel.apiResponses) { r in + NavigationLink { + ScrollView { + Text(r.resp.details) + } + } label: { + Text(r.resp.responseType) + .frame(width: 360, height: 30, alignment: .leading) + } + } + } + } + .navigationViewStyle(.stack) + + Spacer() + + HStack { + TextField("Message...", text: $command) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(minHeight: 30) + Button(action: sendMessage) { + Text("Send") + }.disabled(command.isEmpty) + } + .frame(minHeight: 30) + .padding() + } + } + + func sendMessage() { + DispatchQueue.global().async { + let cmd: String = self.$command.wrappedValue + inProgress = true + command = "" + chatSendCmd(chatModel, ChatCommand.string(cmd)) + inProgress = false + } + } +} + +struct TerminalView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.apiResponses = [ + APIResponse(resp: ChatResponse.response(type: "contactSubscribed", json: "{}"), id: 1), + APIResponse(resp: ChatResponse.response(type: "newChatItem", json: "{}"), id: 2) + ] + return NavigationView { + TerminalView() + .environmentObject(chatModel) + } + + } +} diff --git a/apps/ios/Shared/Views/UserView.swift b/apps/ios/Shared/Views/UserView.swift new file mode 100644 index 0000000000..61e3f5e7ed --- /dev/null +++ b/apps/ios/Shared/Views/UserView.swift @@ -0,0 +1,83 @@ +// +// UserView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 27/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct UserView: View { + @EnvironmentObject var chatModel: ChatModel + var user: User + +// private let controller: chat_ctrl +// +// var chatStore: chat_store + +// @State private var logbuffer = [String]() +// @State private var chatcmd: String = "" +// @State private var chatlog: String = "" +// @FocusState private var focused: Bool + +// func addLine(line: String) { +// print(line) +// logbuffer.append(line) +// if(logbuffer.count > 50) { _ = logbuffer.dropFirst() } +// chatlog = logbuffer.joined(separator: "\n") +// } + + var body: some View { + if chatModel.userChats.isEmpty { + VStack { + Text("Hello chat") + Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") + } + } else { + ChatList() + } + + + +// DispatchQueue.global().async { +// while(true) { +// let msg = String.init(cString: chat_recv_msg(controller)) +// +// DispatchQueue.main.async { +// addLine(line: msg) +// } +// } +// } + +// return VStack { +// ScrollView { +// VStack(alignment: .leading) { +// HStack { Spacer() } +// Text(chatlog) +// .lineLimit(nil) +// .font(.system(.body, design: .monospaced)) +// } +// .frame(maxWidth: .infinity) +// } +// +// TextField("Chat command", text: $chatcmd) +// .focused($focused) +// .onSubmit { +// print(chatcmd) +// var cCmd = chatcmd.cString(using: .utf8)! +//// print(String.init(cString: chat_send_cmd(controller, &cCmd))) +// } +// .textInputAutocapitalization(.never) +// .disableAutocorrection(true) +// .padding() +// } + } + +} + +//struct UserView_Previews: PreviewProvider { +// static var previews: some View { +// UserView() +// } +//} diff --git a/apps/ios/Shared/ProfileView.swift b/apps/ios/Shared/Views/WelcomeView.swift similarity index 59% rename from apps/ios/Shared/ProfileView.swift rename to apps/ios/Shared/Views/WelcomeView.swift index a27f1ec9e2..7db90b11b5 100644 --- a/apps/ios/Shared/ProfileView.swift +++ b/apps/ios/Shared/Views/WelcomeView.swift @@ -1,5 +1,5 @@ // -// ProfileView.swift +// WelcomeView.swift // SimpleX // // Created by Evgeny Poberezkin on 18/01/2022. @@ -7,9 +7,11 @@ import SwiftUI -struct ProfileView: View { +struct WelcomeView: View { + @EnvironmentObject var chatModel: ChatModel @State var displayName: String = "" @State var fullName: String = "" + var body: some View { VStack(alignment: .leading) { Text("Create profile") @@ -20,13 +22,23 @@ struct ProfileView: View { TextField("Display name", text: $displayName) .padding(.bottom) TextField("Full name (optional)", text: $fullName) + .padding(.bottom) + Button("Create") { + let profile = Profile( + displayName: displayName, + fullName: fullName + ) + if let user = chatCreateUser(profile) { + chatModel.currentUser = user + } + } } .padding() } } -struct ProfileView_Previews: PreviewProvider { +struct WelcomeView_Previews: PreviewProvider { static var previews: some View { - ProfileView() + WelcomeView() } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9ccab2ff65..085246786a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ 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 */; }; 5C1AEB82279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */; }; 5C1AEB83279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */; }; 5C1AEB84279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */; }; @@ -17,6 +19,14 @@ 5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; 5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; }; 5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; }; + 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 */; }; 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 */; }; @@ -35,8 +45,8 @@ 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 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */; }; - 5CA05A4D27974EB60002BEB4 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */; }; + 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; + 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; /* End PBXBuildFile section */ @@ -59,11 +69,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a"; sourceTree = ""; }; 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a"; sourceTree = ""; }; 5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C1AEB81279F4A6400247F08 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; 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 = ""; }; 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 = ""; }; @@ -81,7 +97,7 @@ 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 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 5CA05A4E279752D00002BEB4 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -131,6 +147,19 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5C2E260D27A30E2400F70299 /* Views */ = { + isa = PBXGroup; + children = ( + 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, + 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, + 5CA05A4E279752D00002BEB4 /* MessageView.swift */, + 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, + 5C2E260E27A30FDC00F70299 /* ChatView.swift */, + 5C2E261127A30FEA00F70299 /* TerminalView.swift */, + ); + path = Views; + sourceTree = ""; + }; 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( @@ -156,6 +185,7 @@ isa = PBXGroup; children = ( 5C764E88279CBCB3000C6508 /* ChatModel.swift */, + 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, ); path = Model; sourceTree = ""; @@ -178,10 +208,10 @@ children = ( 5C764E87279CBC8E000C6508 /* Model */, 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, + 5C2E260927A2C63500F70299 /* MyPlayground.playground */, 5C764E7F279C7276000C6508 /* dummy.m */, 5CA059C4279559F40002BEB4 /* ContentView.swift */, - 5CA05A4B27974EB60002BEB4 /* ProfileView.swift */, - 5CA05A4E279752D00002BEB4 /* MessageView.swift */, + 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */, @@ -387,11 +417,16 @@ buildActionMask = 2147483647; files = ( 5C764E80279C7276000C6508 /* dummy.m in Sources */, + 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, + 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, - 5CA05A4C27974EB60002BEB4 /* ProfileView.swift in Sources */, + 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, + 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, + 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, + 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -400,11 +435,16 @@ buildActionMask = 2147483647; files = ( 5C764E81279C7276000C6508 /* dummy.m in Sources */, + 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, + 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, - 5CA05A4D27974EB60002BEB4 /* ProfileView.swift in Sources */, + 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, + 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, + 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, + 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From d97a8c1934f00bde493a23bd02fc658ca2a65453 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Sat, 29 Jan 2022 16:06:08 +0400 Subject: [PATCH 21/82] getGroupChat, getGroupChatPreviews_ (#233) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 1 + .../getChatInfoListDirect.sql | 39 --- .../getChatInfoListGroup.sql | 45 --- .../chat_item_queries/getChatItemsMixed.sql | 44 --- .../getDirectChatItemList.sql | 32 -- .../getGroupChatItemList.sql | 38 --- src/Simplex/Chat/Protocol.hs | 4 +- src/Simplex/Chat/Store.hs | 280 ++++++++++++------ src/Simplex/Chat/Util.hs | 3 + src/Simplex/Chat/View.hs | 5 +- 11 files changed, 199 insertions(+), 294 deletions(-) delete mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql delete mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql delete mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql delete mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql delete mode 100644 src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4f9aff7650..5ae2477635 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -127,7 +127,7 @@ processChatCommand user@User {userId, profile} = \case APIGetChats -> CRApiChats <$> withStore (`getChatPreviews` user) APIGetChat cType cId -> case cType of CTDirect -> CRApiDirectChat <$> withStore (\st -> getDirectChat st user cId) - CTGroup -> pure $ CRChatError ChatErrorNotImplemented + CTGroup -> CRApiGroupChat <$> withStore (\st -> getGroupChat st user cId) APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented ChatHelp section -> pure $ CRChatHelp section Welcome -> pure $ CRWelcome user diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7ef963d598..f15eade32a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -118,6 +118,7 @@ data ChatCommand data ChatResponse = CRApiChats {chats :: [AChatPreview]} | CRApiDirectChat {chat :: Chat 'CTDirect} + | CRApiGroupChat {gChat :: Chat 'CTGroup} | CRNewChatItem {chatItem :: AChatItem} | CRCmdAccepted {corr :: CorrId} | CRChatHelp {helpSection :: HelpSection} diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql deleted file mode 100644 index bc5279d517..0000000000 --- a/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListDirect.sql +++ /dev/null @@ -1,39 +0,0 @@ -SELECT - c.contact_id, - cp.display_name, - cp.full_name, - cp.properties, - ci.chat_item_id, - ci.chat_msg_id, - ci.created_by_msg_id, - ci.item_sent, - ci.item_ts, - ci.item_deleted, - ci.item_text, - ci.item_content, - md.msg_delivery_id, - md.chat_ts, - md.agent_msg_meta, - mde.delivery_status, - mde.created_at -FROM contacts c -JOIN contact_profiles cp ON cp.contact_profile_id == c.contact_profile_id -JOIN ( - SELECT contact_id, chat_item_id, MAX(item_ts) MaxDate - FROM chat_items - WHERE item_deleted != 1 - GROUP BY contact_id, chat_item_id -) CIMaxDates ON CIMaxDates.contact_id = c.contact_id -LEFT JOIN chat_items ci ON ci.chat_item_id == CIMaxDates.chat_item_id - AND ci.item_ts == CIMaxDates.MaxDate -JOIN messages m ON m.message_id == ci.created_by_msg_id -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id - AND mde.created_at = MDEMaxDates.MaxDate -WHERE c.user_id = ? -ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql deleted file mode 100644 index e1fbd4db55..0000000000 --- a/src/Simplex/Chat/Migrations/chat_item_queries/getChatInfoListGroup.sql +++ /dev/null @@ -1,45 +0,0 @@ -SELECT - g.group_id, - gp.display_name, - gp.full_name, - gp.properties, - gm.group_member_id, - cp.display_name, - cp.full_name, - cp.properties, - ci.chat_item_id, - ci.chat_msg_id, - ci.created_by_msg_id, - ci.item_sent, - ci.item_ts, - ci.item_deleted, - ci.item_text, - ci.item_content, - md.msg_delivery_id, - md.chat_ts, - md.agent_msg_meta, - mde.delivery_status, - mde.created_at -FROM groups g -JOIN group_profiles gp ON gp.group_profile_id == g.group_profile_id -JOIN ( - SELECT group_id, chat_item_id, MAX(item_ts) MaxDate - FROM chat_items - WHERE item_deleted != 1 - GROUP BY group_id, chat_item_id -) CIMaxDates ON CIMaxDates.group_id = g.group_id -LEFT JOIN chat_items ci ON ci.chat_item_id == CIMaxDates.chat_item_id - AND ci.item_ts == CIMaxDates.MaxDate -LEFT JOIN group_members ON gm.group_member_id == ci.group_member_id -JOIN contact_profiles cp ON cp.contact_profile_id == gm.contact_profile_id -JOIN messages m ON m.message_id == ci.created_by_msg_id -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id - AND mde.created_at = MDEMaxDates.MaxDate -WHERE c.user_id = ? -ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql deleted file mode 100644 index f537cee929..0000000000 --- a/src/Simplex/Chat/Migrations/chat_item_queries/getChatItemsMixed.sql +++ /dev/null @@ -1,44 +0,0 @@ -SELECT - c.contact_id, - cp.display_name, - cp.full_name, - cp.properties, - g.group_id, - gp.display_name, - gp.full_name, - gp.properties, - gm.group_member_id, - gmp.display_name, - gmp.full_name, - gmp.properties, - ci.chat_item_id, - ci.chat_msg_id, - ci.created_by_msg_id, - ci.item_sent, - ci.item_ts, - ci.item_deleted, - ci.item_text, - ci.item_content, - md.msg_delivery_id, - md.chat_ts, - md.agent_msg_meta, - mde.delivery_status, - mde.created_at -FROM chat_items ci -LEFT JOIN contacts c ON c.contact_id == ci.contact_id -JOIN contact_profiles cp ON cp.contact_profile_id == c.contact_profile_id -LEFT JOIN groups g ON g.group_id = ci.group_id -JOIN group_profiles gp ON gp.group_profile_id == g.group_profile_id -LEFT JOIN group_members ON gm.group_member_id == ci.group_member_id -JOIN contact_profiles gmp ON gmp.contact_profile_id == gm.contact_profile_id -JOIN messages m ON m.message_id == ci.created_by_msg_id -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id - AND mde.created_at = MDEMaxDates.MaxDate -WHERE ci.user_id = ? -ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql deleted file mode 100644 index eb6426ba96..0000000000 --- a/src/Simplex/Chat/Migrations/chat_item_queries/getDirectChatItemList.sql +++ /dev/null @@ -1,32 +0,0 @@ -SELECT - c.contact_id, - cp.display_name, - cp.full_name, - cp.properties, - ci.chat_item_id, - ci.chat_msg_id, - ci.created_by_msg_id, - ci.item_sent, - ci.item_ts, - ci.item_deleted, - ci.item_text, - ci.item_content, - md.msg_delivery_id, - md.chat_ts, - md.agent_msg_meta, - mde.delivery_status, - mde.created_at -FROM contacts c -JOIN contact_profiles cp ON cp.contact_profile_id == c.contact_profile_id -LEFT JOIN chat_items ci ON ci.contact_id == c.contact_id -JOIN messages m ON m.message_id == ci.created_by_msg_id -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id - AND mde.created_at = MDEMaxDates.MaxDate -WHERE c.user_id = ? AND c.contact_id = ? -ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql b/src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql deleted file mode 100644 index 5e35a9b095..0000000000 --- a/src/Simplex/Chat/Migrations/chat_item_queries/getGroupChatItemList.sql +++ /dev/null @@ -1,38 +0,0 @@ -SELECT - g.group_id, - gp.display_name, - gp.full_name, - gp.properties, - gm.group_member_id, - cp.display_name, - cp.full_name, - cp.properties, - ci.chat_item_id, - ci.chat_msg_id, - ci.created_by_msg_id, - ci.item_sent, - ci.item_ts, - ci.item_deleted, - ci.item_text, - ci.item_content, - md.msg_delivery_id, - md.chat_ts, - md.agent_msg_meta, - mde.delivery_status, - mde.created_at -FROM groups g -JOIN group_profiles gp ON gp.group_profile_id == g.group_profile_id -LEFT JOIN chat_items ci ON ci.group_id == g.group_id -LEFT JOIN group_members ON gm.group_member_id == ci.group_member_id -JOIN contact_profiles cp ON cp.contact_profile_id == gm.contact_profile_id -JOIN messages m ON m.message_id == ci.created_by_msg_id -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MDEMaxDates ON MDEMaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MDEMaxDates.msg_delivery_id - AND mde.created_at = MDEMaxDates.MaxDate -WHERE g.user_id = ? AND g.group_id = ? -ORDER BY ci.item_ts DESC diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 8e3f8a776d..8d16239848 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -9,7 +9,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} module Simplex.Chat.Protocol where @@ -26,6 +25,7 @@ import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Types +import Simplex.Chat.Util (eitherToMaybe) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) @@ -240,7 +240,7 @@ toCMEventTag = \case XOk -> XOk_ cmEventTagT :: Text -> Maybe CMEventTag -cmEventTagT = either (const Nothing) Just . strDecode . encodeUtf8 +cmEventTagT = eitherToMaybe . strDecode . encodeUtf8 serializeCMEventTag :: CMEventTag -> Text serializeCMEventTag = decodeLatin1 . strEncode diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index a5e1fc1238..9b11ca3a91 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -106,6 +106,7 @@ module Simplex.Chat.Store createNewChatItem, getChatPreviews, getDirectChat, + getGroupChat, ) where @@ -124,11 +125,13 @@ import Data.Either (rights) 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.Time.Clock (UTCTime, getCurrentTime) +import Data.Time (fromGregorian, secondsToDiffTime) +import Data.Time.Clock (UTCTime (UTCTime), getCurrentTime) import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB @@ -140,7 +143,7 @@ import Simplex.Chat.Migrations.M20220122_pending_group_messages import Simplex.Chat.Migrations.M20220125_chat_items import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Chat.Util (singleFieldJSON) +import Simplex.Chat.Util (eitherToMaybe, singleFieldJSON) 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 (..)) @@ -405,7 +408,7 @@ getContact_ db userId localDisplayName = do toContact [(contactId, displayName, fullName, viaGroup)] = let profile = Profile {displayName, fullName} in Right Contact {contactId, localDisplayName, profile, activeConn = undefined, viaGroup} - toContact _ = Left $ SEContactNotFound localDisplayName + toContact _ = Left $ SEContactNotFoundByName localDisplayName connection :: [ConnectionRow] -> Either StoreError Connection connection (connRow : _) = Right $ toConnection connRow connection _ = Left $ SEContactNotReady localDisplayName @@ -433,7 +436,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 = '' @@ -627,14 +630,14 @@ getContactConnections st userId displayName = 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 + 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 + AND cs.local_display_name = :display_name |] [":user_id" := userId, ":display_name" := displayName] where - connections [] = Left $ SEContactNotFound displayName + connections [] = Left $ SEContactNotFoundByName displayName 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) @@ -709,9 +712,7 @@ matchReceivedProbe st userId _from@Contact {contactId} (Probe probe) = DB.execute db "INSERT INTO received_probes (contact_id, probe, probe_hash, user_id) VALUES (?,?,?,?)" (contactId, probe, probeHash, userId) case contactNames of [] -> pure Nothing - cName : _ -> - either (const Nothing) Just - <$> runExceptT (getContact_ db userId cName) + cName : _ -> eitherToMaybe <$> runExceptT (getContact_ db userId cName) matchReceivedProbeHash :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> ProbeHash -> m (Maybe (Contact, Probe)) matchReceivedProbeHash st userId _from@Contact {contactId} (ProbeHash probeHash) = @@ -750,9 +751,7 @@ matchSentProbe st userId _from@Contact {contactId} (Probe probe) = (userId, probe, contactId) case contactNames of [] -> pure Nothing - cName : _ -> - either (const Nothing) Just - <$> runExceptT (getContact_ db userId cName) + cName : _ -> eitherToMaybe <$> runExceptT (getContact_ db userId cName) mergeContactRecords :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Contact -> m () mergeContactRecords st userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = @@ -834,13 +833,17 @@ getConnectionEntity st User {userId, userContactId} agentConnId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, + g.group_id, g.local_display_name, + -- GroupInfo {groupProfile} + gp.display_name, gp.full_name, + -- 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, - -- user 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 + 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 @@ -850,8 +853,8 @@ getConnectionEntity st User {userId, userContactId} agentConnId = WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? |] (groupMemberId, userId, userContactId) - toGroupAndMember :: Connection -> (Int64, GroupName, GroupName, Text) :. GroupMemberRow :. GroupMemberRow -> (GroupInfo, GroupMember) - toGroupAndMember c ((groupId, localDisplayName, displayName, fullName) :. memberRow :. userMemberRow) = + toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember c (((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. memberRow) = let member = toGroupMember userContactId memberRow membership = toGroupMember userContactId userMemberRow in ( GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership}, @@ -917,7 +920,7 @@ createNewGroup st gVar user groupProfile = -- | 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 GroupInfo -createGroupInvitation st user@User {userId} contact GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = +createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = liftIOEither . withTransaction st $ \db -> do getGroupInvitationLdn_ db >>= \case Nothing -> createGroupInvitation_ db @@ -937,7 +940,7 @@ createGroupInvitation st user@User {userId} contact GroupInvitation {fromMember, DB.execute db "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id) VALUES (?, ?, ?, ?)" (profileId, localDisplayName, connRequest, userId) groupId <- insertedRowId db _ <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown - membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact $ contactId contact) + membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) pure $ GroupInfo {groupId, localDisplayName, groupProfile, membership} -- TODO return the last connection that is ready, not any last connection @@ -990,24 +993,33 @@ getGroupInfo st user gName = liftIOEither . withTransaction st $ \db -> getGroup getGroupInfo_ :: DB.Connection -> User -> GroupName -> IO (Either StoreError GroupInfo) getGroupInfo_ db User {userId, userContactId} gName = - firstRow (toGroupInfo userContactId) (SEGroupNotFound gName) $ + firstRow (toGroupInfo userContactId) (SEGroupNotFoundByName gName) $ DB.query db [sql| - SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, - 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 + SELECT + -- GroupInfo + g.group_id, g.local_display_name, + -- GroupInfo {groupProfile} + gp.display_name, gp.full_name, + -- 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 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.local_display_name = ? AND g.user_id = ? AND m.contact_id = ? + 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.local_display_name = ? AND g.user_id = ? AND mu.contact_id = ? |] (gName, userId, userContactId) -toGroupInfo :: Int64 -> (Int64, GroupName, GroupName, Text) :. GroupMemberRow -> GroupInfo -toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName) :. memberRow) = - let membership = toGroupMember userContactId memberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text) :. GroupMemberRow + +toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName) :. userMemberRow) = + let membership = toGroupMember userContactId userMemberRow in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember] @@ -1054,7 +1066,7 @@ getGroupInvitation st user localDisplayName = where getConnRec_ :: DB.Connection -> User -> ExceptT StoreError IO (Maybe ConnReqInvitation) getConnRec_ db User {userId} = ExceptT $ do - firstRow fromOnly (SEGroupNotFound localDisplayName) $ + 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) @@ -1062,6 +1074,8 @@ getGroupInvitation st user localDisplayName = 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} @@ -1069,6 +1083,11 @@ 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 -> @@ -1355,15 +1374,19 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, + g.group_id, g.local_display_name, + -- GroupInfo {groupProfile} + gp.display_name, gp.full_name, + -- 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, - 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, - -- user 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 + 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 contacts ct ON ct.contact_id = m.contact_id JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id @@ -1380,8 +1403,8 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = |] (userId, contactId, userContactId) where - toGroupAndMember :: [(Int64, GroupName, GroupName, Text) :. GroupMemberRow :. MaybeConnectionRow :. GroupMemberRow] -> Maybe (GroupInfo, GroupMember) - toGroupAndMember [(groupId, localDisplayName, displayName, fullName) :. memberRow :. connRow :. userMemberRow] = + toGroupAndMember :: [GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow] -> Maybe (GroupInfo, GroupMember) + toGroupAndMember [((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. memberRow :. connRow] = let member = toGroupMember userContactId memberRow membership = toGroupMember userContactId userMemberRow in Just @@ -1767,7 +1790,7 @@ getMsgDeliveryId_ db connId agentMsgId = [sql| SELECT msg_delivery_id FROM msg_deliveries m - WHERE m.connection_id = ? AND m.agent_msg_id == ? + WHERE m.connection_id = ? AND m.agent_msg_id = ? LIMIT 1; |] (connId, agentMsgId) @@ -1845,7 +1868,11 @@ getChatPreviews st user = liftIO . withTransaction st $ \db -> do directChatPreviews <- getDirectChatPreviews_ db user groupChatPreviews <- getGroupChatPreviews_ db user - pure $ directChatPreviews <> groupChatPreviews + pure $ sortOn (Down . ts) (directChatPreviews <> groupChatPreviews) + where + ts :: AChatPreview -> UTCTime + ts (AChatPreview _ _ Nothing) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo createdAt + ts (AChatPreview _ _ (Just (CChatItem _ (ChatItem _ CIMeta {itemTs} _)))) = itemTs getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] getDirectChatPreviews_ db User {userId} = do @@ -1862,7 +1889,7 @@ getDirectChatPreviews_ db User {userId} = do -- Contact {activeConn} 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, - -- CChatItem 'CTDirect + -- ChatItem ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -1873,10 +1900,10 @@ getDirectChatPreviews_ db User {userId} = do WHERE item_deleted != 1 GROUP BY contact_id ) CIMaxDates ON CIMaxDates.contact_id = c.contact_id - LEFT JOIN chat_items ci ON ci.contact_id == CIMaxDates.contact_id - AND ci.item_ts == CIMaxDates.MaxDate + LEFT JOIN chat_items ci ON ci.contact_id = CIMaxDates.contact_id + AND ci.item_ts = CIMaxDates.MaxDate WHERE ct.user_id = ? - ORDER BY ci.item_ts ASC + ORDER BY ci.item_ts DESC |] (Only userId) where @@ -1887,8 +1914,9 @@ getDirectChatPreviews_ db User {userId} = do in AChatPreview SCTDirect (DirectChat contact) ci_ getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] -getGroupChatPreviews_ db User {userId, userContactId} = - map toGroupChatPreview +getGroupChatPreviews_ db User {userId, userContactId} = do + tz <- getCurrentTimeZone + map (toGroupChatPreview tz) <$> DB.query db [sql| @@ -1898,38 +1926,57 @@ getGroupChatPreviews_ db User {userId, userContactId} = -- GroupInfo {groupProfile} gp.display_name, gp.full_name, -- 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, + 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 + pu.display_name, pu.full_name, + -- ChatItem + ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, 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, + -- GroupMember {memberProfile} + 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 g.group_id = mu.group_id + 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(item_ts) MaxDate + FROM chat_items + WHERE item_deleted != 1 + GROUP BY group_id + ) GIMaxDates ON GIMaxDates.group_id = g.group_id + LEFT JOIN chat_items ci ON ci.group_id = GIMaxDates.group_id + AND ci.item_ts = GIMaxDates.MaxDate + 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 |] (userId, userContactId) where - toGroupChatPreview :: (Int64, GroupName, GroupName, Text) :. GroupMemberRow -> AChatPreview - toGroupChatPreview ((groupId, localDisplayName, displayName, fullName) :. userMemberRow) = + toGroupChatPreview :: TimeZone -> GroupInfoRow :. MaybeGroupChatItemRow -> AChatPreview + toGroupChatPreview tz (((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. ciRow_) = let membership = toGroupMember userContactId userMemberRow groupInfo = GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} - in AChatPreview SCTGroup (GroupChat groupInfo) Nothing + ci_ = toMaybeGroupChatItem tz userContactId ciRow_ + in AChatPreview SCTGroup (GroupChat groupInfo) ci_ getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Chat 'CTDirect) getDirectChat st user contactId = liftIOEither . withTransaction st $ \db -> runExceptT $ do - contact <- getContact_' db user contactId + contact <- ExceptT $ getContact_' db user contactId chatItems <- liftIO $ getDirectChatItems_ db user contactId pure $ Chat (DirectChat contact) chatItems -getContact_' :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact +-- TODO reuse in contact queries +getContact_' :: DB.Connection -> User -> Int64 -> IO (Either StoreError Contact) getContact_' db User {userId} contactId = - ExceptT $ - toContact - <$> DB.query - db - [sql| + firstRow toContact' (SEContactNotFound contactId) $ + DB.query + db + [sql| SELECT -- Contact ct.contact_id, ct.local_display_name, ct.via_group, @@ -1943,11 +1990,7 @@ getContact_' db User {userId} contactId = JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.contact_id = ? |] - (userId, contactId) - where - toContact :: [ContactRow] -> Either StoreError Contact - toContact (contactRow : _) = Right $ toContact' contactRow - toContact _ = Left $ SEContactNotFoundById contactId + (userId, contactId) getDirectChatItems_ :: DB.Connection -> User -> Int64 -> IO [CChatItem 'CTDirect] getDirectChatItems_ db User {userId} contactId = do @@ -1963,6 +2006,61 @@ getDirectChatItems_ db User {userId} contactId = do |] (userId, contactId) +getGroupChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Chat 'CTGroup) +getGroupChat st user groupId = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + groupInfo <- ExceptT $ getGroupInfo_' db user groupId + chatItems <- ExceptT $ getGroupChatItems_ db user groupId + pure $ Chat (GroupChat groupInfo) chatItems + +-- TODO reuse in group queries +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, + -- GroupInfo {groupProfile} + gp.display_name, gp.full_name, + -- 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 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) + +getGroupChatItems_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError [CChatItem 'CTGroup]) +getGroupChatItems_ db User {userId, userContactId} groupId = 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.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, + -- GroupMember {memberProfile} + 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.item_ts ASC + |] + (userId, groupId) + type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, UTCTime) type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe UTCTime) @@ -1979,25 +2077,23 @@ toMaybeDirectChatItem tz (Just itemId, Just itemTs, Just itemContent, Just itemT Just $ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) toMaybeDirectChatItem _ _ = Nothing --- getGroupChatItemList :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m ChatItemList --- getGroupChatItemList st userId groupId = --- liftIO . withTransaction st $ \db -> --- DB.query --- db --- [sql| --- ... --- |] --- (userId, groupId) +type GroupChatItemRow = ChatItemRow :. MaybeGroupMemberRow --- getChatItemsMixed :: MonadUnliftIO m => SQLiteStore -> UserId -> m [AnyChatItem] --- getChatItemsMixed st userId = --- liftIO . withTransaction st $ \db -> --- DB.query --- db --- [sql| --- ... --- |] --- (Only userId) +type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow + +toGroupChatItem :: TimeZone -> Int64 -> GroupChatItemRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, createdAt) :. memberRow_) = + let ciMeta = mkCIMeta itemId itemText tz itemTs createdAt + member_ = toMaybeGroupMember userContactId memberRow_ + in case (itemContent, member_) of + (ACIContent d@SMDSnd ciContent, Nothing) -> Right $ CChatItem d (ChatItem CIGroupSnd ciMeta ciContent) + (ACIContent d@SMDRcv ciContent, Just member) -> Right $ CChatItem d (ChatItem (CIGroupRcv member) ciMeta ciContent) + _ -> Left $ SEBadChatItem itemId + +toMaybeGroupChatItem :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> Maybe (CChatItem 'CTGroup) +toMaybeGroupChatItem tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just createdAt) :. memberRow_) = + eitherToMaybe $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, createdAt) :. memberRow_) +toMaybeGroupChatItem _ _ _ = Nothing -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. @@ -2056,13 +2152,14 @@ randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGe data StoreError = SEDuplicateName - | SEContactNotFoundById Int64 - | SEContactNotFound {contactName :: ContactName} + | SEContactNotFound {contactId :: Int64} + | SEContactNotFoundByName {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink | SEUserContactLinkNotFound | SEContactRequestNotFound {contactName :: ContactName} - | SEGroupNotFound {groupName :: GroupName} + | SEGroupNotFound {groupId :: Int64} + | SEGroupNotFoundByName {groupName :: GroupName} | SEGroupWithoutUser | SEDuplicateGroupMember | SEGroupAlreadyJoined @@ -2077,6 +2174,7 @@ data StoreError | SEUniqueID | SEInternal {message :: String} | SENoMsgDelivery {connId :: Int64, agentMsgId :: AgentMsgId} + | SEBadChatItem {itemId :: Int64} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 7be20f54e0..f4663879bb 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -34,3 +34,6 @@ singleFieldJSON tagModifier = J.sumEncoding = J.ObjectWithSingleField, J.omitNothingFields = True } + +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 9923c3a39d..5eae8f13ed 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -36,6 +36,7 @@ responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case CRApiChats chats -> api [sShow chats] CRApiDirectChat chat -> api [sShow chat] + CRApiGroupChat gChat -> api [sShow gChat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRCmdAccepted _ -> r [] CRChatHelp section -> case section of @@ -470,9 +471,9 @@ viewChatError = \case -- 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 From c0199a38fdf9c08f009df367e00a47c43a7c5ec2 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Sat, 29 Jan 2022 16:53:24 +0400 Subject: [PATCH 22/82] add readme for ios setup (#234) --- apps/ios/README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/ios/README.md diff --git a/apps/ios/README.md b/apps/ios/README.md new file mode 100644 index 0000000000..7e358e9c2d --- /dev/null +++ b/apps/ios/README.md @@ -0,0 +1,43 @@ +# Setup for iOS + +## Prerequisites + +- `mac2ios` executable and in PATH: + + https://github.com/zw3rk/mobile-core-tools + +- Folders: + + ```sh + mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim + ``` + +## Update binaries + +1. Download binary distribution from `aarch64-darwin:lib:simplex-chat.aarch64-darwin` job at +https://ci.zw3rk.com/jobset/zw3rk/simplex-chat +and extract binaries to `./apps/ios/Libraries/mac`. + +2. Prepare binaries for iOS and for Xcode simulator: + + ```sh + chmod +w ./apps/ios/Libraries/mac/* + cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/ios + cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/sim + for f in ./apps/ios/Libraries/ios/*; do mac2ios $f; done | wc -l + for f in ./apps/ios/Libraries/sim/*; do mac2ios -s $f; done | wc -l + ``` + +3. Put binaries into `./apps/ios/Libraries`. + + For simulator: + + ```sh + cp ./apps/ios/Libraries/sim/* ./apps/ios/Libraries + ``` + + For iOS: + + ```sh + cp ./apps/ios/Libraries/ios/* ./apps/ios/Libraries + ``` From 8425be0612dba300be19521a4a113ab775af875d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 29 Jan 2022 20:21:37 +0000 Subject: [PATCH 23/82] use aeson fork with nullableToObject option to make JSON compatible with Swift (#236) --- cabal.project | 7 ++++++- package.yaml | 5 ++--- sha256map.nix | 3 ++- simplex-chat.cabal | 15 ++++++--------- src/Simplex/Chat/Controller.hs | 15 +++++++-------- src/Simplex/Chat/Messages.hs | 22 ++++++++++++---------- src/Simplex/Chat/Protocol.hs | 14 +++++++------- src/Simplex/Chat/Store.hs | 8 ++++---- src/Simplex/Chat/Types.hs | 19 +++++++++---------- src/Simplex/Chat/Util.hs | 16 ---------------- stack.yaml | 11 ++++++++++- 11 files changed, 65 insertions(+), 70 deletions(-) diff --git a/cabal.project b/cabal.project index 9672e6c7ef..2d18b117cf 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,12 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: 6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b + tag: 137ff7043d49feb3b350f56783c9b64a62bc636a + +source-repository-package + type: git + location: git://github.com/simplex-chat/aeson.git + tag: 3eb66f9a68f103b5f1489382aad89f5712a64db7 source-repository-package type: git diff --git a/package.yaml b/package.yaml index b991ee8f50..8aa1ae3f13 100644 --- a/package.yaml +++ b/package.yaml @@ -12,9 +12,9 @@ extra-source-files: - README.md dependencies: - - aeson == 1.5.* + - aeson == 2.0.* - ansi-terminal >= 0.10 && < 0.12 - - attoparsec == 0.13.* + - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 - bytestring == 0.10.* @@ -36,7 +36,6 @@ dependencies: - time == 1.9.* - unliftio == 0.2.* - unliftio-core == 0.2.* - - unordered-containers == 0.2.* library: source-dirs: src diff --git a/sha256map.nix b/sha256map.nix index cc95f1ca7f..fc95b7a306 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,6 @@ { - "git://github.com/simplex-chat/simplexmq.git"."6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b" = "0yhxngrvis2ykcrx2mzin1c2bch1p7r6m4lqazdybrkas0p349qc"; + "git://github.com/simplex-chat/simplexmq.git"."137ff7043d49feb3b350f56783c9b64a62bc636a" = "1jlxpmg40qkvisbf03082yrw6k2ah9dsw8pn1jqc0cyz5250qc49"; + "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 17e5cc734d..56fa5acbc4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -45,9 +45,9 @@ 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.* + , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , bytestring ==0.10.* @@ -69,7 +69,6 @@ library , time ==1.9.* , unliftio ==0.2.* , unliftio-core ==0.2.* - , unordered-containers ==0.2.* default-language: Haskell2010 executable simplex-chat @@ -80,9 +79,9 @@ 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.* + , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , bytestring ==0.10.* @@ -105,7 +104,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 @@ -121,10 +119,10 @@ test-suite simplex-chat-test 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.* @@ -149,5 +147,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/Controller.hs b/src/Simplex/Chat/Controller.hs index f15eade32a..a86a943b81 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -24,12 +24,11 @@ import Numeric.Natural import Simplex.Chat.Messages import Simplex.Chat.Store (StoreError) import Simplex.Chat.Types -import Simplex.Chat.Util (enumJSON, singleFieldJSON) import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) -import Simplex.Messaging.Parsers (dropPrefix) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (CorrId) import System.IO (Handle) import UnliftIO.STM @@ -189,8 +188,8 @@ data ChatResponse deriving (Show, Generic) instance ToJSON ChatResponse where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "CR" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "CR" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" data ChatError = ChatError {errorType :: ChatErrorType} @@ -201,8 +200,8 @@ data ChatError deriving (Show, Exception, Generic) instance ToJSON ChatError where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "Chat" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "Chat" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" data ChatErrorType = CEGroupUserRole @@ -231,8 +230,8 @@ data ChatErrorType deriving (Show, Exception, Generic) instance ToJSON ChatErrorType where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "CE" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "CE" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b0cda969eb..0aa53ab3a8 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -30,11 +30,10 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Chat.Util (enumJSON, singleFieldJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..)) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) data ChatType = CTDirect | CTGroup @@ -56,8 +55,8 @@ data JSONChatInfo deriving (Generic) instance ToJSON JSONChatInfo where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCInfo" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCInfo" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" instance ToJSON (ChatInfo c) where toJSON = J.toJSON . jsonChatInfo @@ -92,11 +91,14 @@ data JSONCIDirection | JCIDirectRcv | JCIGroupSnd | JCIGroupRcv {groupMember :: GroupMember} - deriving (Generic) + deriving (Generic, Show) + +instance FromJSON JSONCIDirection where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" instance ToJSON JSONCIDirection where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" instance ToJSON (CIDirection c d) where toJSON = J.toJSON . jsonCIDirection @@ -240,11 +242,11 @@ data JSONCIContent deriving (Generic) instance FromJSON JSONCIContent where - parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "JCI" + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" instance ToJSON JSONCIContent where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "JCI" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" jsonCIContent :: CIContent d -> JSONCIContent jsonCIContent = \case diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 8d16239848..5107ada98a 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -18,7 +18,7 @@ import qualified Data.Aeson as J 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 qualified Data.Aeson.KeyMap as JM import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Database.SQLite.Simple.FromField (FromField (..)) @@ -255,7 +255,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" @@ -284,8 +284,8 @@ chatToAppMessage :: ChatMessage -> AppMessage chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} where event = serializeCMEventTag . toCMEventTag $ chatMsgEvent - o :: [(Text, J.Value)] -> J.Object - o = H.fromList + o :: [(J.Key, J.Value)] -> J.Object + o = JM.fromList params = case chatMsgEvent of XMsgNew content -> o ["content" .= content] XFile fileInv -> o ["file" .= fileInv] @@ -302,9 +302,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 9b11ca3a91..281b65bd7d 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -143,12 +143,12 @@ import Simplex.Chat.Migrations.M20220122_pending_group_messages import Simplex.Chat.Migrations.M20220125_chat_items import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Chat.Util (eitherToMaybe, singleFieldJSON) +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.Parsers (dropPrefix) +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util (liftIOEither, (<$$>)) import System.FilePath (takeFileName) import UnliftIO.STM @@ -2178,5 +2178,5 @@ data StoreError deriving (Show, Exception, Generic) instance ToJSON StoreError where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "SE" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "SE" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b413d28071..d6ab2a2399 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -32,11 +32,10 @@ import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Chat.Util (singleFieldJSON) import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix) +import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) class IsContact a where @@ -244,11 +243,11 @@ data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show, Generic) instance FromJSON InvitedBy where - parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "IB" + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "IB" instance ToJSON InvitedBy where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "IB" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "IB" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy toInvitedBy userCtId (Just ctId) @@ -484,11 +483,11 @@ data RcvFileStatus deriving (Eq, Show, Generic) instance FromJSON RcvFileStatus where - parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "RFS" + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RFS" instance ToJSON RcvFileStatus where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "RFS" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "RFS" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" data RcvFileInfo = RcvFileInfo { filePath :: FilePath, @@ -522,8 +521,8 @@ data FileTransfer = FTSnd {sndFileTransfers :: [SndFileTransfer]} | FTRcv RcvFil deriving (Show, Generic) instance ToJSON FileTransfer where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "FT" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "FT" + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index f4663879bb..a5e4c8b1e0 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -1,7 +1,6 @@ module Simplex.Chat.Util where import Control.Monad (when) -import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Text.Encoding (decodeUtf8With) @@ -20,20 +19,5 @@ whenM ba a = ba >>= (`when` a) unlessM :: Monad m => m Bool -> m () -> m () unlessM b = ifM b $ pure () -enumJSON :: (String -> String) -> J.Options -enumJSON tagModifier = - J.defaultOptions - { J.constructorTagModifier = tagModifier, - J.allNullaryToStringTag = True - } - -singleFieldJSON :: (String -> String) -> J.Options -singleFieldJSON tagModifier = - J.defaultOptions - { J.constructorTagModifier = tagModifier, - J.sumEncoding = J.ObjectWithSingleField, - J.omitNothingFields = True - } - eitherToMaybe :: Either a b -> Maybe b eitherToMaybe = either (const Nothing) Just diff --git a/stack.yaml b/stack.yaml index 0e2d618983..9c282b18e0 100644 --- a/stack.yaml +++ b/stack.yaml @@ -38,11 +38,20 @@ extra-deps: - cryptostore-0.2.1.0@sha256:9896e2984f36a1c8790f057fd5ce3da4cbcaf8aa73eb2d9277916886978c5b19,3881 - simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079 - 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: 6fe3bfa980847c074b4cb0b9f3ea01cc5e6c567b + commit: 137ff7043d49feb3b350f56783c9b64a62bc636a # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 + - github: simplex-chat/aeson + commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 - github: simplex-chat/haskell-terminal commit: f708b00009b54890172068f168bf98508ffcd495 From 7e2f365c1c98b983ea1469c7e30af6d0f74fe798 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Sun, 30 Jan 2022 00:35:20 +0400 Subject: [PATCH 24/82] ios: group chat preview (#235) --- apps/ios/Shared/Model/ChatModel.swift | 10 +++--- apps/ios/Shared/Views/ChatPreviewView.swift | 34 +++++++++++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 766ac78cc2..0d50bdb867 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -45,13 +45,13 @@ struct ChatPreview: Identifiable, Codable { enum ChatInfo: Identifiable, Codable { case direct(contact: Contact) -// case group() + case group(groupInfo: GroupInfo) var displayName: String { get { switch self { case let .direct(contact): return "@\(contact.localDisplayName)" -// case let .group(groupInfo, _): return "#\(groupInfo.localDisplayName)" + case let .group(groupInfo): return "#\(groupInfo.localDisplayName)" } } } @@ -60,7 +60,7 @@ enum ChatInfo: Identifiable, Codable { get { switch self { case let .direct(contact): return "@\(contact.contactId)" -// case let .group(contact): return group.id + case let .group(groupInfo): return "#\(groupInfo.groupId)" } } } @@ -69,7 +69,7 @@ enum ChatInfo: Identifiable, Codable { get { switch self { case .direct(_): return "direct" -// case let .group(_): return "group" + case .group(_): return "group" } } } @@ -78,7 +78,7 @@ enum ChatInfo: Identifiable, Codable { get { switch self { case let .direct(contact): return contact.contactId -// case let .group(contact): return group.id + case let .group(groupInfo): return groupInfo.groupId } } } diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift index 4b05988ab2..0b49f45cb4 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -18,16 +18,30 @@ struct ChatPreviewView: View { struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { - ChatPreviewView(chatPreview: ChatPreview( - chatInfo: .direct(contact: Contact( - contactId: 123, - localDisplayName: "ep", - profile: Profile( - displayName: "ep", - fullName: "Ep" - ), - viaGroup: nil + Group{ + ChatPreviewView(chatPreview: ChatPreview( + chatInfo: .direct(contact: Contact( + contactId: 123, + localDisplayName: "ep", + profile: Profile( + displayName: "ep", + fullName: "Ep" + ), + viaGroup: nil + )) )) - )) + + ChatPreviewView(chatPreview: ChatPreview( + chatInfo: .group(groupInfo: GroupInfo( + groupId: 123, + localDisplayName: "team", + groupProfile: GroupProfile( + displayName: "team", + fullName: "My Team" + ) + )) + )) + } + .previewLayout(.fixed(width: 300, height: 70)) } } From cb602dd377089caf74b12e8aeed13bd551194b14 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 29 Jan 2022 23:37:02 +0000 Subject: [PATCH 25/82] show received messages in chat, send command on Enter, fix Date parsing (#237) * refactor UI and API, send command on Enter, fix Date parsing * UI sheets to create connection and groups * show received messages * readme --- apps/ios/README.md | 16 +- apps/ios/Shared/ContentView.swift | 9 +- apps/ios/Shared/Model/ChatModel.swift | 107 ++++++++++--- apps/ios/Shared/Model/JSON.swift | 38 +++++ apps/ios/Shared/Model/SimpleXAPI.swift | 149 +++++++++++------- .../MyPlayground.playground/Contents.swift | 37 ++++- .../timeline.xctimeline | 2 +- apps/ios/Shared/SimpleXApp.swift | 14 -- apps/ios/Shared/Views/ChatListView.swift | 31 +++- apps/ios/Shared/Views/ChatPreviewView.swift | 24 +-- apps/ios/Shared/Views/ChatView.swift | 28 +++- .../Shared/Views/Helpers/ChatHeaderView.swift | 43 +++++ .../Views/Helpers/CreateGroupView.swift | 21 +++ .../Views/Helpers/InviteContactView.swift | 21 +++ .../Views/{ => Helpers}/MessageView.swift | 0 .../Shared/Views/Helpers/ScanQRCodeView.swift | 21 +++ .../Views/Helpers/SendMessageView.swift | 47 ++++++ apps/ios/Shared/Views/TerminalView.swift | 40 +++-- apps/ios/Shared/Views/WelcomeView.swift | 4 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 70 ++++++-- 20 files changed, 546 insertions(+), 176 deletions(-) create mode 100644 apps/ios/Shared/Model/JSON.swift create mode 100644 apps/ios/Shared/Views/Helpers/ChatHeaderView.swift create mode 100644 apps/ios/Shared/Views/Helpers/CreateGroupView.swift create mode 100644 apps/ios/Shared/Views/Helpers/InviteContactView.swift rename apps/ios/Shared/Views/{ => Helpers}/MessageView.swift (100%) create mode 100644 apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift create mode 100644 apps/ios/Shared/Views/Helpers/SendMessageView.swift diff --git a/apps/ios/README.md b/apps/ios/README.md index 7e358e9c2d..3a637d7973 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -2,11 +2,7 @@ ## Prerequisites -- `mac2ios` executable and in PATH: - - https://github.com/zw3rk/mobile-core-tools - -- Folders: +- Prepare folders: ```sh mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim @@ -14,11 +10,9 @@ ## Update binaries -1. Download binary distribution from `aarch64-darwin:lib:simplex-chat.aarch64-darwin` job at -https://ci.zw3rk.com/jobset/zw3rk/simplex-chat -and extract binaries to `./apps/ios/Libraries/mac`. +1. Extract binaries to `./apps/ios/Libraries/mac`. -2. Prepare binaries for iOS and for Xcode simulator: +2. Prepare binaries: ```sh chmod +w ./apps/ios/Libraries/mac/* @@ -30,13 +24,11 @@ and extract binaries to `./apps/ios/Libraries/mac`. 3. Put binaries into `./apps/ios/Libraries`. - For simulator: - ```sh cp ./apps/ios/Libraries/sim/* ./apps/ios/Libraries ``` - For iOS: + or: ```sh cp ./apps/ios/Libraries/ios/* ./apps/ios/Libraries diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2cceaba497..5a107ce5a5 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -71,7 +71,14 @@ struct ContentView: View { var body: some View { if let user = chatModel.currentUser { ChatListView(user: user) - .onAppear { chatSendCmd(chatModel, .apiGetChats) } + .onAppear { + do { + let chats = try apiGetChats() + chatModel.chatPreviews = chats + } catch { + print(error) + } + } } else { WelcomeView() } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0d50bdb867..cd87d3e035 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -14,7 +14,7 @@ final class ChatModel: ObservableObject { @Published var chats: Dictionary = [:] @Published var chatPreviews: [ChatPreview] = [] @Published var chatItems: [ChatItem] = [] - @Published var apiResponses: [APIResponse] = [] + @Published var terminalItems: [TerminalItem] = [] } struct User: Codable { @@ -25,6 +25,14 @@ struct User: Codable { var activeUser: Bool } +let sampleUser = User( + userId: 1, + userContactId: 1, + localDisplayName: "alice", + profile: sampleProfile, + activeUser: true +) + typealias ContactName = String typealias GroupName = String @@ -34,7 +42,12 @@ struct Profile: Codable { var fullName: String } -struct ChatPreview: Identifiable, Codable { +let sampleProfile = Profile( + displayName: "alice", + fullName: "Alice" +) + +struct ChatPreview: Identifiable, Decodable { var chatInfo: ChatInfo var lastChatItem: ChatItem? @@ -43,6 +56,11 @@ struct ChatPreview: Identifiable, Codable { } } +enum ChatType: String { + case direct + case group +} + enum ChatInfo: Identifiable, Codable { case direct(contact: Contact) case group(groupInfo: GroupInfo) @@ -65,11 +83,11 @@ enum ChatInfo: Identifiable, Codable { } } - var apiType: String { + var chatType: ChatType { get { switch self { - case .direct(_): return "direct" - case .group(_): return "group" + case .direct: return .direct + case .group: return .group } } } @@ -84,9 +102,18 @@ enum ChatInfo: Identifiable, Codable { } } -class Chat: Codable { +let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact) + +let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo) + +class Chat: Decodable { var chatInfo: ChatInfo var chatItems: [ChatItem] + + init(chatInfo: ChatInfo, chatItems: [ChatItem]) { + self.chatInfo = chatInfo + self.chatItems = chatItems + } } struct Contact: Identifiable, Codable { @@ -98,6 +125,12 @@ struct Contact: Identifiable, Codable { var id: String { get { "@\(contactId)" } } } +let sampleContact = Contact( + contactId: 1, + localDisplayName: "alice", + profile: sampleProfile +) + struct GroupInfo: Identifiable, Codable { var groupId: Int64 var localDisplayName: GroupName @@ -106,16 +139,32 @@ struct GroupInfo: Identifiable, Codable { var id: String { get { "#\(groupId)" } } } +let sampleGroupInfo = GroupInfo( + groupId: 1, + localDisplayName: "team", + groupProfile: sampleGroupProfile +) + struct GroupProfile: Codable { var displayName: String var fullName: String } +let sampleGroupProfile = GroupProfile( + displayName: "team", + fullName: "My Team" +) + struct GroupMember: Codable { } -struct ChatItem: Identifiable, Codable { +struct AChatItem: Decodable { + var chatInfo: ChatInfo + var chatItem: ChatItem +} + +struct ChatItem: Identifiable, Decodable { var chatDir: CIDirection var meta: CIMeta var content: CIContent @@ -123,21 +172,21 @@ struct ChatItem: Identifiable, Codable { var id: Int64 { get { meta.itemId } } } -enum CIDirection: Codable { +enum CIDirection: Decodable { case directSnd case directRcv case groupSnd case groupRcv(GroupMember) } -struct CIMeta: Codable { +struct CIMeta: Decodable { var itemId: Int64 var itemTs: Date var itemText: String var createdAt: Date } -enum CIContent: Codable { +enum CIContent: Decodable { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) // files etc. @@ -152,24 +201,44 @@ enum CIContent: Codable { } } -enum MsgContent: Codable { +enum MsgContent { case text(String) - case unknown(type: String, text: String, json: String) - case invalid(json: String) + case unknown(type: String, text: String) + case invalid(error: String) - init(from: Decoder) throws { - self = .invalid(json: "") - } - var string: String { get { switch self { - case let .text(str): return str - case .unknown: return "unknown" + case let .text(text): return text + case let .unknown(_, text): return text case .invalid: return "invalid" } } } + + 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)) + } + } } //func parseMsgContent(_ mc: SomeMsgContent) -> MsgContent { 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/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b2dd350fb0..fe022d8d55 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -7,15 +7,16 @@ // import Foundation +import UIKit private var chatStore: chat_store? private var chatController: chat_ctrl? -private let jsonDecoder = JSONDecoder() -private let jsonEncoder = JSONEncoder() +private let jsonDecoder = getJSONDecoder() +private let jsonEncoder = getJSONEncoder() enum ChatCommand { case apiGetChats - case apiGetChatItems(type: String, id: Int64) + case apiGetChatItems(type: ChatType, id: Int64) case string(String) case help @@ -25,7 +26,7 @@ enum ChatCommand { case .apiGetChats: return "/api/v1/chats" case let .apiGetChatItems(type, id): - return "/api/v1/chat/items/\(type)/\(id)" + return "/api/v1/chat/\(type)/\(id)" case let .string(str): return str case .help: return "/help" @@ -34,30 +35,26 @@ enum ChatCommand { } } -struct APIResponse: Identifiable { - var resp: ChatResponse - var id: Int64 -} - -struct APIResponseJSON: Decodable { +struct APIResponse: Decodable { var resp: ChatResponse } -enum ChatResponse: Codable { +enum ChatResponse: Decodable, Error { case response(type: String, json: String) case apiChats(chats: [ChatPreview]) case apiDirectChat(chat: Chat) // direct/ or group/, same as ChatPreview.id -// case chatHelp(String) // case newSentInvitation case contactConnected(contact: Contact) + case newChatItem(chatItem: AChatItem) var responseType: String { get { switch self { case let .response(type, _): return "* \(type)" - case .apiChats(_): return "apiChats" - case .apiDirectChat(_): return "apiDirectChat" - case .contactConnected(_): return "contactConnected" + case .apiChats: return "apiChats" + case .apiDirectChat: return "apiDirectChat" + case .contactConnected: return "contactConnected" + case .newChatItem: return "newChatItem" } } } @@ -69,6 +66,39 @@ enum ChatResponse: Codable { case let .apiChats(chats): return String(describing: chats) case let .apiDirectChat(chat): return String(describing: chat) case let .contactConnected(contact): return String(describing: contact) + case let .newChatItem(chatItem): return String(describing: chatItem) + } + } + } +} + +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 } } } @@ -95,41 +125,52 @@ func chatCreateUser(_ p: Profile) -> User? { return user } -func chatSendCmd(_ chatModel: ChatModel, _ cmd: ChatCommand) { +func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! - processAPIResponse(chatModel, - apiResponse( - chat_send_cmd(getChatCtrl(), &c)!)) +// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? +// DispatchQueue.main.async { +// termId += 1 +// chatModel.terminalItems.append(.cmd(termId, cmd)) +// } + return chatResponse(chat_send_cmd(getChatCtrl(), &c)!) } -func chatRecvMsg(_ chatModel: ChatModel) { - processAPIResponse(chatModel, - apiResponse( - chat_recv_msg(getChatCtrl())!)) +func chatRecvMsg() throws -> ChatResponse { + chatResponse(chat_recv_msg(getChatCtrl())!) } -private func processAPIResponse(_ chatModel: ChatModel, _ res: APIResponse?) { - if let r = res { - DispatchQueue.main.async { - chatModel.apiResponses.append(r) - switch r.resp { - case let .apiChats(chats): - chatModel.chatPreviews = chats - case let .apiDirectChat(chat): - chatModel.chats[chat.chatInfo.id] = chat - case let .contactConnected(contact): - chatModel.chatPreviews.insert( - ChatPreview(chatInfo: .direct(contact: contact)), - at: 0 - ) - default: return +func apiGetChats() throws -> [ChatPreview] { + let r = try chatSendCmd(.apiGetChats) + switch r { + case let .apiChats(chats): return chats + default: throw r + } +} -// case let .response(type, _): -// chatModel.chatItems.append(ChatItem( -// ts: Date.now, -// content: .text(type) -// )) - } +func apiGetChatItems(type: ChatType, id: Int64) throws -> Chat { + let r = try chatSendCmd(.apiGetChatItems(type: type, id: id)) + switch r { + case let .apiDirectChat(chat): return chat + default: throw r + } +} + +func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { + DispatchQueue.main.async { + chatModel.terminalItems.append(.resp(Date.now, res)) + switch res { + case let .contactConnected(contact): + chatModel.chatPreviews.insert( + ChatPreview(chatInfo: .direct(contact: contact)), + at: 0 + ) + case let .newChatItem(aChatItem): + let ci = aChatItem.chatInfo + let chat = chatModel.chats[ci.id] ?? Chat(chatInfo: ci, chatItems: []) + chatModel.chats[ci.id] = chat + chat.chatItems.append(aChatItem.chatItem) + default: + print("unsupported response: ", res) } } } @@ -139,19 +180,19 @@ private struct UserResponse: Decodable { var error: String? } -private var respId: Int64 = 0 - -private func apiResponse(_ cjson: UnsafePointer) -> APIResponse? { +private func chatResponse(_ cjson: UnsafePointer) -> ChatResponse { let s = String.init(cString: cjson) - print("apiResponse", s) + print("chatResponse", s) let d = s.data(using: .utf8)! -// TODO is there a way to do it without copying the data? e.g: +// 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(APIResponseJSON.self, from: d) - return APIResponse(resp: r.resp, id: respId) + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp } catch { print (error) } @@ -164,11 +205,7 @@ private func apiResponse(_ cjson: UnsafePointer) -> APIResponse? { } json = prettyJSON(j) } - respId += 1 - return APIResponse( - resp: ChatResponse.response(type: type ?? "invalid", json: json ?? s), - id: respId - ) + return ChatResponse.response(type: type ?? "invalid", json: json ?? s) } func prettyJSON(_ obj: NSDictionary) -> String? { diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift index b81ba746c3..b46b3c1374 100644 --- a/apps/ios/Shared/MyPlayground.playground/Contents.swift +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -37,18 +37,45 @@ let ct = Contact( //,"agentConnId":"cTdFNkprSHhZZmZhdWFQVg==","createdAt":"2022-01-27T19:47:08.891646Z","connStatus":"ready"},"localDisplayName":"ep"}}}}] //""" // -//let data = str.data(using: .utf8)! + +//let str = """ +//{"resp":{"apiDirectChat":{"chat":{"chatInfo":{"direct":{"contact":{"contactId":2,"localDisplayName":"ep","profile":{"displayName":"ep","fullName":"Evgeny"},"activeConn":{"connId":1,"agentConnId":"bUk2OXZlN3lfNXFaVWRWMQ==","connLevel":0,"connType":"contact","connStatus":"ready","entityId":2,"createdAt":"2022-01-29T11:21:18.669786Z"}}}},"chatItems":[{"chatDir":{"directSnd":{}},"meta":{"itemId":1,"itemTs":"2022-01-29T11:21:47.947865Z","itemText":"hello","localItemTs":"2022-01-29T11:21:47.947865Z","createdAt":"2022-01-29T11:21:47.947865Z"},"content":{"sndMsgContent":{"msgContent":{"type":"text","text":"hello"}}}},{"chatDir":{"directRcv":{}},"meta":{"itemId":2,"itemTs":"2022-01-29T11:22:08Z","itemText":"hi","localItemTs":"2022-01-29T11:22:08Z","createdAt":"2022-01-29T11:22:08.563959Z"},"content":{"rcvMsgContent":{"msgContent":{"type":"text","text":"hi"}}}}]}}}} +//""" + +let str = "\"2022-01-29T11:21:47Z\"" + +let data = str.data(using: .utf8)! let jsonDecoder = JSONDecoder() -//let r: [ChatPreview] = try! jsonDecoder.decode([ChatPreview].self, from: data) -// -//print(r) +let df1 = DateFormatter() +df1.locale = Locale(identifier: "en_US_POSIX") +df1.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" +df1.timeZone = TimeZone(secondsFromGMT: 0) + +let df2 = DateFormatter() +df2.locale = Locale(identifier: "en_US_POSIX") +df2.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" +df2.timeZone = TimeZone(secondsFromGMT: 0) + +jsonDecoder.dateDecodingStrategy = .iso8601 // .custom { decoder in +// let container = try decoder.singleValueContainer() +// let string = try container.decode(String.self) +// if let date = df1.date(from: string) ?? df2.date(from: string) { +// return date +// } +// throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)") +//} + + +let r: Date = try! jsonDecoder.decode(Date.self, from: data) + +print(r) struct Test: Decodable { var name: String var id: Int64 = 0 } -jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!) +//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!) diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline index 62084a5f42..7b48f2c7ec 100644 --- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -3,7 +3,7 @@ version = "3.0"> diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 5af995644a..0666668196 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -10,23 +10,9 @@ import SwiftUI @main struct SimpleXApp: App { @StateObject private var chatModel = ChatModel() -// let store: chat_store init() { hs_init(0, nil) -// let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" -// var cstr = dataDir.cString(using: .utf8)! -// store = chat_init_store(&cstr) -// let user = String.init(cString: chat_get_user(store)) -// print(user) -// if user != "{}" { -// chatModel.currentUser = parseJSON(user) -// var data = "{ \"displayName\": \"test\", \"fullName\": \"ios test\" }".cString(using: .utf8)! -// chat_create_user(store, &data) -// } -// controller = chat_start(store) -// var cmd = "/help".cString(using: .utf8)! -// print(String.init(cString: chat_send_cmd(controller, &cmd))) } var body: some Scene { diff --git a/apps/ios/Shared/Views/ChatListView.swift b/apps/ios/Shared/Views/ChatListView.swift index d77d68a06d..5a27e2aa06 100644 --- a/apps/ios/Shared/Views/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatListView.swift @@ -14,16 +14,25 @@ struct ChatListView: View { var body: some View { DispatchQueue.global().async { - while(true) { chatRecvMsg(chatModel) } + while(true) { + do { + try processReceivedMsg(chatModel, chatRecvMsg()) + } catch { + print("error receiving message: ", error) + } + } } return VStack { // if chatModel.chats.isEmpty { - VStack { - Text("Hello chat") - Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") - } +// VStack { +// Text("Hello chat") +// Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") +// } // } + + ChatHeaderView() + NavigationView { List { NavigationLink { @@ -36,7 +45,12 @@ struct ChatListView: View { NavigationLink { ChatView(chatInfo: chatPreview.chatInfo) .onAppear { - chatSendCmd(chatModel, .apiGetChatItems(type: "direct", id: chatPreview.chatInfo.apiId)) + do { + let chat = try apiGetChatItems(type: .direct, id: chatPreview.chatInfo.apiId) + chatModel.chats[chat.chatInfo.id] = chat + } catch { + print("apiGetChatItems", error) + } } } label: { ChatPreviewView(chatPreview: chatPreview) @@ -51,6 +65,9 @@ struct ChatListView: View { //struct ChatListView_Previews: PreviewProvider { // static var previews: some View { -// ChatListView() +// let chatModel = ChatModel() +// chatModel.chatPreviews = [] +// return ChatListView(user: sampleUser) +// .environmentObject(chatModel) // } //} diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift index 0b49f45cb4..6dda250ba5 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -19,28 +19,8 @@ struct ChatPreviewView: View { struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatPreviewView(chatPreview: ChatPreview( - chatInfo: .direct(contact: Contact( - contactId: 123, - localDisplayName: "ep", - profile: Profile( - displayName: "ep", - fullName: "Ep" - ), - viaGroup: nil - )) - )) - - ChatPreviewView(chatPreview: ChatPreview( - chatInfo: .group(groupInfo: GroupInfo( - groupId: 123, - localDisplayName: "team", - groupProfile: GroupProfile( - displayName: "team", - fullName: "My Team" - ) - )) - )) + ChatPreviewView(chatPreview: ChatPreview(chatInfo: sampleDirectChatInfo)) + ChatPreviewView(chatPreview: ChatPreview(chatInfo: sampleGroupChatInfo)) } .previewLayout(.fixed(width: 300, height: 70)) } diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/ChatView.swift index eb3b89ee19..9e4a040057 100644 --- a/apps/ios/Shared/Views/ChatView.swift +++ b/apps/ios/Shared/Views/ChatView.swift @@ -10,6 +10,8 @@ import SwiftUI struct ChatView: View { @EnvironmentObject var chatModel: ChatModel + @State var inProgress: Bool = false + var chatInfo: ChatInfo var body: some View { VStack { @@ -26,12 +28,28 @@ struct ChatView: View { } else { Text("unexpected: chat not found...") } + + Spacer() + + SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } } + + func sendMessage(_ msg: String) { + + } } -//struct ChatView_Previews: PreviewProvider { -// static var previews: some View { -// ChatView() -// } -//} +struct ChatView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chats = [ + "@1": Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [] + ) + ] + return ChatView(chatInfo: sampleDirectChatInfo) + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift new file mode 100644 index 0000000000..d2cf7fa70e --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift @@ -0,0 +1,43 @@ +// +// ChatHeaderView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatHeaderView: View { + @State private var showAddChat = false + @State private var inviteContact = false + @State private var scanQRCode = false + @State private var createGroup = false + + var body: some View { + HStack { + Button("Edit", action: {}) + Spacer() + Text("Your chats") + Spacer() + Button { showAddChat = true } label: { + Image(systemName: "square.and.pencil") + } + .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { + Button("Invite contact") { inviteContact = true } + Button("Scan QR code") { scanQRCode = true } + Button("Create group") { createGroup = true } + } + .sheet(isPresented: $inviteContact, content: { InviteContactView() }) + .sheet(isPresented: $scanQRCode, content: { ScanQRCodeView() }) + .sheet(isPresented: $createGroup, content: { CreateGroupView() }) + } + .padding(.horizontal) + } +} + +struct ChatHeaderView_Previews: PreviewProvider { + static var previews: some View { + ChatHeaderView() + } +} diff --git a/apps/ios/Shared/Views/Helpers/CreateGroupView.swift b/apps/ios/Shared/Views/Helpers/CreateGroupView.swift new file mode 100644 index 0000000000..89a65f1ecd --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/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/Helpers/InviteContactView.swift b/apps/ios/Shared/Views/Helpers/InviteContactView.swift new file mode 100644 index 0000000000..0c6355815a --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/InviteContactView.swift @@ -0,0 +1,21 @@ +// +// InviteContactView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct InviteContactView: View { + var body: some View { + Text("InviteContactView") + } +} + +struct InviteContactView_Previews: PreviewProvider { + static var previews: some View { + InviteContactView() + } +} diff --git a/apps/ios/Shared/Views/MessageView.swift b/apps/ios/Shared/Views/Helpers/MessageView.swift similarity index 100% rename from apps/ios/Shared/Views/MessageView.swift rename to apps/ios/Shared/Views/Helpers/MessageView.swift diff --git a/apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift b/apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift new file mode 100644 index 0000000000..1a21463ba3 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift @@ -0,0 +1,21 @@ +// +// ScanQRCodeView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ScanQRCodeView: View { + var body: some View { + Text("ScanQRCodeView") + } +} + +struct ScanQRCodeView_Previews: PreviewProvider { + static var previews: some View { + ScanQRCodeView() + } +} diff --git a/apps/ios/Shared/Views/Helpers/SendMessageView.swift b/apps/ios/Shared/Views/Helpers/SendMessageView.swift new file mode 100644 index 0000000000..dc9fe1bfb3 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/SendMessageView.swift @@ -0,0 +1,47 @@ +// +// 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 var command: String = "" + + var body: some View { + HStack { + TextField("Message...", text: $command) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .frame(minHeight: 30) + .onSubmit(submit) + + if (inProgress) { + ProgressView() + .frame(width: 40, height: 20, alignment: .center) + } else { + Button("Send", action :submit) + .disabled(command.isEmpty) + } + } + .frame(minHeight: 30) + .padding() + } + + func submit() { + sendMessage(command) + command = "" + } +} + +struct SendMessageView_Previews: PreviewProvider { + static var previews: some View { + SendMessageView(sendMessage: { print ($0) }) + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 11521fb680..a58f1ea9ab 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -10,20 +10,19 @@ import SwiftUI struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel - @State var command: String = "" @State var inProgress: Bool = false var body: some View { VStack { ScrollView { LazyVStack { - ForEach(chatModel.apiResponses) { r in + ForEach(chatModel.terminalItems) { item in NavigationLink { ScrollView { - Text(r.resp.details) + Text(item.details) } } label: { - Text(r.resp.responseType) + Text(item.label) .frame(width: 360, height: 30, alignment: .leading) } } @@ -33,25 +32,24 @@ struct TerminalView: View { Spacer() - HStack { - TextField("Message...", text: $command) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(minHeight: 30) - Button(action: sendMessage) { - Text("Send") - }.disabled(command.isEmpty) - } - .frame(minHeight: 30) - .padding() + SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } } - func sendMessage() { + func sendMessage(_ cmdStr: String) { + let cmd = ChatCommand.string(cmdStr) + chatModel.terminalItems.append(.cmd(Date.now, cmd)) + DispatchQueue.global().async { - let cmd: String = self.$command.wrappedValue inProgress = true - command = "" - chatSendCmd(chatModel, ChatCommand.string(cmd)) + do { + let r = try chatSendCmd(cmd) + DispatchQueue.main.async { + chatModel.terminalItems.append(.resp(Date.now, r)) + } + } catch { + print(error) + } inProgress = false } } @@ -60,9 +58,9 @@ struct TerminalView: View { struct TerminalView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() - chatModel.apiResponses = [ - APIResponse(resp: ChatResponse.response(type: "contactSubscribed", json: "{}"), id: 1), - APIResponse(resp: ChatResponse.response(type: "newChatItem", json: "{}"), id: 2) + chatModel.terminalItems = [ + .resp(Date.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), + .resp(Date.now, ChatResponse.response(type: "newChatItem", json: "{}")) ] return NavigationView { TerminalView() diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift index 7db90b11b5..759090631c 100644 --- a/apps/ios/Shared/Views/WelcomeView.swift +++ b/apps/ios/Shared/Views/WelcomeView.swift @@ -20,8 +20,12 @@ struct WelcomeView: View { Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.") .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( diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 085246786a..5a68218041 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -9,10 +9,6 @@ /* 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 */; }; - 5C1AEB82279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */; }; - 5C1AEB83279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */; }; - 5C1AEB84279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */; }; - 5C1AEB85279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */; }; 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; @@ -27,6 +23,10 @@ 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 */; }; + 5C44B6A027A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; + 5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; + 5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; + 5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.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 */; }; @@ -35,6 +35,10 @@ 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 */; }; + 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 */; }; @@ -49,6 +53,14 @@ 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; + 5CCD403127A5F1C600368C90 /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */; }; + 5CCD403227A5F1C600368C90 /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */; }; + 5CCD403427A5F6DF00368C90 /* InviteContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */; }; + 5CCD403527A5F6DF00368C90 /* InviteContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */; }; + 5CCD403727A5F9A200368C90 /* ScanQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */; }; + 5CCD403827A5F9A200368C90 /* ScanQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */; }; + 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; + 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,8 +82,6 @@ /* Begin PBXFileReference section */ 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; - 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a"; sourceTree = ""; }; - 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a"; sourceTree = ""; }; 5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C1AEB81279F4A6400247F08 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; @@ -80,12 +90,16 @@ 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 = ""; }; + 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; sourceTree = ""; }; + 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.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 = ""; }; + 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 = ""; }; @@ -99,6 +113,10 @@ 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 = ""; }; 5CA05A4E279752D00002BEB4 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; + 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHeaderView.swift; sourceTree = ""; }; + 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteContactView.swift; sourceTree = ""; }; + 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeView.swift; sourceTree = ""; }; + 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -107,11 +125,11 @@ buildActionMask = 2147483647; files = ( 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C1AEB84279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */, + 5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */, 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */, - 5C1AEB82279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */, + 5C44B6A027A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */, 5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -121,11 +139,11 @@ buildActionMask = 2147483647; files = ( 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C1AEB85279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */, + 5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */, 5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */, - 5C1AEB83279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a in Frameworks */, + 5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */, 5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -150,9 +168,9 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 5C5F4AC227A5E9AF00B51EF1 /* Helpers */, 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, - 5CA05A4E279752D00002BEB4 /* MessageView.swift */, 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, 5C2E260E27A30FDC00F70299 /* ChatView.swift */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, @@ -160,14 +178,27 @@ path = Views; sourceTree = ""; }; + 5C5F4AC227A5E9AF00B51EF1 /* Helpers */ = { + isa = PBXGroup; + children = ( + 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, + 5CA05A4E279752D00002BEB4 /* MessageView.swift */, + 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */, + 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, + 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */, + 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( 5C1AEB7F279F4A6400247F08 /* libffi.a */, 5C1AEB80279F4A6400247F08 /* libgmp.a */, 5C1AEB81279F4A6400247F08 /* libgmpxx.a */, - 5C1AEB7E279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD-ghc8.10.7.a */, - 5C1AEB7D279F4A6400247F08 /* libHSsimplex-chat-1.0.2-FYXW0143sf06WiRQx9DgCD.a */, + 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */, + 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */, ); path = Libraries; sourceTree = ""; @@ -186,6 +217,7 @@ children = ( 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, + 5C9FD96A27A56D4D0075386C /* JSON.swift */, ); path = Model; sourceTree = ""; @@ -419,12 +451,18 @@ 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, + 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, + 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 5CCD403127A5F1C600368C90 /* ChatHeaderView.swift in Sources */, 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, + 5CCD403427A5F6DF00368C90 /* InviteContactView.swift in Sources */, 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5CCD403727A5F9A200368C90 /* ScanQRCodeView.swift in Sources */, + 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, ); @@ -437,12 +475,18 @@ 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, + 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, + 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 5CCD403227A5F1C600368C90 /* ChatHeaderView.swift in Sources */, 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, + 5CCD403527A5F6DF00368C90 /* InviteContactView.swift in Sources */, 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5CCD403827A5F9A200368C90 /* ScanQRCodeView.swift in Sources */, + 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, ); From 15a91278d64688e944475d50a5733bc75b7c9b5f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 30 Jan 2022 10:49:13 +0000 Subject: [PATCH 26/82] API to send direct and group messages (#239) * API to send direct and group messages * update API parsing --- src/Simplex/Chat.hs | 54 ++++--- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Messages.hs | 8 + src/Simplex/Chat/Store.hs | 275 +++++++++++++++------------------ src/Simplex/Chat/View.hs | 3 +- 5 files changed, 171 insertions(+), 174 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5ae2477635..fe133fd6a1 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -126,9 +126,21 @@ processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResp processChatCommand user@User {userId, profile} = \case APIGetChats -> CRApiChats <$> withStore (`getChatPreviews` user) APIGetChat cType cId -> case cType of - CTDirect -> CRApiDirectChat <$> withStore (\st -> getDirectChat st user cId) - CTGroup -> CRApiGroupChat <$> withStore (\st -> getGroupChat st user cId) + CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st userId cId) + CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId) APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented + APISendMessage cType chatId mc -> 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 ChatHelp section -> pure $ CRChatHelp section Welcome -> pure $ CRWelcome user AddContact -> procCmd $ do @@ -183,17 +195,15 @@ processChatCommand user@User {userId, profile} = \case withAgent $ \a -> rejectContact a agentContactConnId agentInvitationId pure $ CRContactRequestRejected cName SendMessage cName msg -> do - contact <- withStore $ \st -> getContact st userId cName + contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg - ci <- sendDirectChatItem userId contact (XMsgNew mc) (CISndMsgContent mc) - setActive $ ActiveC cName - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci + processChatCommand user $ APISendMessage CTDirect contactId mc NewGroup gProfile -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) AddMember gName cName memRole -> do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withStore $ \st -> (,) <$> getGroup st user gName <*> getContact st userId cName + (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) $ throwChatError CEGroupUserRole @@ -227,7 +237,7 @@ processChatCommand user@User {userId, profile} = \case pure $ CRUserAcceptedGroupSent g MemberRole _gName _cName _mRole -> throwChatError $ CECommandError "unsupported" RemoveMember gName cName -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName case find ((== cName) . (localDisplayName :: GroupMember -> ContactName)) members of Nothing -> throwChatError $ CEGroupMemberNotFound cName Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus} -> do @@ -239,14 +249,14 @@ processChatCommand user@User {userId, profile} = \case withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved pure $ CRUserDeletedMember gInfo m LeaveGroup gName -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName + Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName procCmd $ do void $ sendGroupMessage members XGrpLeave mapM_ deleteMemberConnection members withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft pure $ CRLeftMemberUser gInfo DeleteGroup gName -> do - g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroup st user gName + g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroupByName st user gName let s = memberStatus membership canDelete = memberRole (membership :: GroupMember) == GROwner @@ -257,18 +267,15 @@ processChatCommand user@User {userId, profile} = \case mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g pure $ CRGroupDeletedUser gInfo - ListMembers gName -> CRGroupMembers <$> withStore (\st -> getGroup st user gName) + ListMembers gName -> CRGroupMembers <$> withStore (\st -> getGroupByName st user gName) ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` user) SendGroupMessage gName msg -> do - group@(Group gInfo@GroupInfo {membership} _) <- withStore $ \st -> getGroup st user gName - unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + groupId <- withStore $ \st -> getGroupIdByName st user gName let mc = MCText $ safeDecodeUtf8 msg - ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) - setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + processChatCommand user $ APISendMessage CTGroup groupId mc SendFile cName f -> 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 -> @@ -279,7 +286,7 @@ processChatCommand user@User {userId, profile} = \case pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci SendGroupFile gName f -> do (fileSize, chSize) <- checkSndFile f - Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroup st user gName + 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 @@ -1296,9 +1303,10 @@ withStore action = chatCommandP :: Parser ChatCommand chatCommandP = - "/api/v1/chats" $> APIGetChats - <|> "/api/v1/chat/" *> (APIGetChat <$> ("direct/" $> CTDirect <|> "group/" $> CTGroup) <*> A.decimal) - <|> "/api/v1/chat/items?count=" *> (APIGetChatItems <$> A.decimal) + "/get chats" $> APIGetChats + <|> "/get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal) + <|> "/get chatItems count=" *> (APIGetChatItems <$> A.decimal) + <|> "/send msg " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress @@ -1316,7 +1324,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)) @@ -1335,6 +1343,8 @@ chatCommandP = <|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat <|> ("/version" <|> "/v") $> ShowVersion where + chatTypeP = "@" $> CTDirect <|> "#" $> CTGroup + 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 a86a943b81..9dd0aac971 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -22,6 +22,7 @@ import Data.Text (Text) import GHC.Generics (Generic) import Numeric.Natural import Simplex.Chat.Messages +import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) @@ -80,6 +81,7 @@ data ChatCommand = APIGetChats | APIGetChat ChatType Int64 | APIGetChatItems Int + | APISendMessage ChatType Int64 MsgContent | ChatHelp HelpSection | Welcome | AddContact @@ -116,8 +118,7 @@ data ChatCommand data ChatResponse = CRApiChats {chats :: [AChatPreview]} - | CRApiDirectChat {chat :: Chat 'CTDirect} - | CRApiGroupChat {gChat :: Chat 'CTGroup} + | CRApiChat {chat :: AChat} | CRNewChatItem {chatItem :: AChatItem} | CRCmdAccepted {corr :: CorrId} | CRChatHelp {helpSection :: HelpSection} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 0aa53ab3a8..c7b26de99f 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -153,6 +153,14 @@ instance ToJSON (ChatPreview 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 + -- | type to show the list of chats, with one last message in each data AChatPreview = forall c. AChatPreview (SChatType c) (ChatInfo c) (Maybe (CChatItem c)) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 281b65bd7d..f867744eed 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -27,7 +27,9 @@ module Simplex.Chat.Store createDirectContact, getContactGroupNames, deleteContact, + getContactByName, getContact, + getContactIdByName, updateUserProfile, updateContactProfile, getUserContacts, @@ -50,6 +52,9 @@ module Simplex.Chat.Store createGroupInvitation, getGroup, getGroupInfo, + getGroupIdByName, + getGroupByName, + getGroupInfoByName, getGroupMembers, deleteGroup, getUserGroups, @@ -306,10 +311,6 @@ deleteContact st userId displayName = |] [":user_id" := userId, ":display_name" := displayName] -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 () updateUserProfile st User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} | displayName == newName = @@ -370,54 +371,27 @@ toContact' ((contactId, localDisplayName, viaGroup, displayName, fullName) :. co activeConn = toConnection connRow in Contact {contactId, localDisplayName, profile, activeConn, viaGroup} +toContactOrError :: (Int64, ContactName, Maybe Int64, ContactName, Text) :. MaybeConnectionRow -> Either StoreError Contact +toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName) :. connRow) = + let profile = Profile {displayName, fullName} + in case toMaybeConnection connRow of + Just activeConn -> + Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup} + _ -> 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 $ SEContactNotFoundByName 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 = @@ -664,12 +638,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 @@ -680,7 +654,7 @@ 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} = @@ -698,21 +672,21 @@ matchReceivedProbe :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> Pro 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 + case contactIds of [] -> pure Nothing - cName : _ -> eitherToMaybe <$> 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) = @@ -721,7 +695,7 @@ 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 @@ -730,28 +704,28 @@ matchReceivedProbeHash st userId _from@Contact {contactId} (ProbeHash probeHash) DB.execute db "INSERT INTO received_probes (contact_id, probe_hash, user_id) VALUES (?,?,?)" (contactId, probeHash, userId) 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 : _ -> eitherToMaybe <$> 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} = @@ -922,15 +896,15 @@ createGroupInvitation :: 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 -> getGroupInfo_ 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) + <$> 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 @@ -945,13 +919,19 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv -- 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 $ 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 -getGroup_ db user gName = do - gInfo <- ExceptT $ getGroupInfo_ db user gName +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 @@ -967,8 +947,8 @@ deleteGroup st User {userId} (Group GroupInfo {groupId, localDisplayName} member 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) - 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 -> User -> m [GroupInfo] getUserGroupDetails st User {userId, userContactId} = @@ -988,32 +968,11 @@ getUserGroupDetails st User {userId, userContactId} = |] (userId, userContactId) -getGroupInfo :: StoreMonad m => SQLiteStore -> User -> GroupName -> m GroupInfo -getGroupInfo st user gName = liftIOEither . withTransaction st $ \db -> getGroupInfo_ db user gName - -getGroupInfo_ :: DB.Connection -> User -> GroupName -> IO (Either StoreError GroupInfo) -getGroupInfo_ db User {userId, userContactId} gName = - firstRow (toGroupInfo userContactId) (SEGroupNotFoundByName gName) $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, - -- GroupInfo {groupProfile} - gp.display_name, gp.full_name, - -- 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 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.local_display_name = ? AND g.user_id = ? AND mu.contact_id = ? - |] - (gName, 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) :. GroupMemberRow @@ -1057,7 +1016,8 @@ getGroupInvitation :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Rece getGroupInvitation st user localDisplayName = liftIOEither . withTransaction st $ \db -> runExceptT $ do cReq <- getConnRec_ db user - Group groupInfo@GroupInfo {membership} members <- getGroup_ db user localDisplayName + 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) -> @@ -1883,10 +1843,8 @@ getDirectChatPreviews_ db User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, - -- Contact {profile} - cp.display_name, cp.full_name, - -- Contact {activeConn} + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, + -- 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, -- ChatItem @@ -1922,20 +1880,16 @@ getGroupChatPreviews_ db User {userId, userContactId} = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, - -- GroupInfo {groupProfile} - gp.display_name, gp.full_name, - -- GroupInfo {membership} + g.group_id, g.local_display_name, gp.display_name, gp.full_name, + -- 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, - -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, -- ChatItem ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at, - -- GroupMember + -- 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, - -- GroupMember {memberProfile} p.display_name, p.full_name FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -1963,37 +1917,53 @@ getGroupChatPreviews_ db User {userId, userContactId} = do ci_ = toMaybeGroupChatItem tz userContactId ciRow_ in AChatPreview SCTGroup (GroupChat groupInfo) ci_ -getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Chat 'CTDirect) -getDirectChat st user contactId = +getDirectChat :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m (Chat 'CTDirect) +getDirectChat st userId contactId = liftIOEither . withTransaction st $ \db -> runExceptT $ do - contact <- ExceptT $ getContact_' db user contactId - chatItems <- liftIO $ getDirectChatItems_ db user contactId + contact <- ExceptT $ getContact_ db userId contactId + chatItems <- liftIO $ getDirectChatItems_ db userId contactId pure $ Chat (DirectChat contact) chatItems --- TODO reuse in contact queries -getContact_' :: DB.Connection -> User -> Int64 -> IO (Either StoreError Contact) -getContact_' db User {userId} contactId = - firstRow toContact' (SEContactNotFound contactId) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, - -- Contact {profile} - cp.display_name, cp.full_name, - -- Contact {activeConn} - 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 - JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.contact_id = ? - |] - (userId, contactId) +getContactIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 +getContactIdByName st userId cName = + liftIOEither . withTransaction st $ \db -> getContactIdByName_ db userId cName -getDirectChatItems_ :: DB.Connection -> User -> Int64 -> IO [CChatItem 'CTDirect] -getDirectChatItems_ db User {userId} contactId = do +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 + +-- TODO return the last connection that is ready, not any last connection +-- requires updating connection status +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, + -- 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 + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + |] + (userId, contactId) + ) + +getDirectChatItems_ :: DB.Connection -> UserId -> Int64 -> IO [CChatItem 'CTDirect] +getDirectChatItems_ db userId contactId = do tz <- getCurrentTimeZone map (toDirectChatItem tz) <$> DB.query @@ -2009,26 +1979,27 @@ getDirectChatItems_ db User {userId} contactId = do getGroupChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Chat 'CTGroup) getGroupChat st user groupId = liftIOEither . withTransaction st $ \db -> runExceptT $ do - groupInfo <- ExceptT $ getGroupInfo_' db user groupId + groupInfo <- ExceptT $ getGroupInfo_ db user groupId chatItems <- ExceptT $ getGroupChatItems_ db user groupId pure $ Chat (GroupChat groupInfo) chatItems --- TODO reuse in group queries -getGroupInfo_' :: DB.Connection -> User -> Int64 -> IO (Either StoreError GroupInfo) -getGroupInfo_' db User {userId, userContactId} groupId = +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, - -- GroupInfo {groupProfile} - gp.display_name, gp.full_name, - -- GroupInfo {membership} + g.group_id, g.local_display_name, gp.display_name, gp.full_name, + -- 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, - -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -2051,7 +2022,6 @@ getGroupChatItems_ db User {userId, userContactId} groupId = do -- 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, - -- GroupMember {memberProfile} p.display_name, p.full_name FROM chat_items ci LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id @@ -2061,6 +2031,15 @@ getGroupChatItems_ db User {userId, userContactId} groupId = do |] (userId, groupId) +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) + type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, UTCTime) type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe UTCTime) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5eae8f13ed..7af42cd1ae 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -35,8 +35,7 @@ serializeChatResponse = unlines . map unStyle . responseToView "" responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case CRApiChats chats -> api [sShow chats] - CRApiDirectChat chat -> api [sShow chat] - CRApiGroupChat gChat -> api [sShow gChat] + CRApiChat chat -> api [sShow chat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRCmdAccepted _ -> r [] CRChatHelp section -> case section of From 3b19aaf1d178aed7b47c85eed35caacc22b2b989 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 30 Jan 2022 18:27:20 +0000 Subject: [PATCH 27/82] iOS: send/receive messages in chats, connect via QR code (#238) * send messages from chats * update API to use chat IDs * send messages to groups * generate invitation QR code * connect via QR code --- apps/ios/README.md | 35 ---------- apps/ios/Shared/Model/ChatModel.swift | 28 ++++---- apps/ios/Shared/Model/SimpleXAPI.swift | 66 ++++++++++++++----- .../MyPlayground.playground/Contents.swift | 1 + .../timeline.xctimeline | 2 +- apps/ios/Shared/Views/ChatListView.swift | 5 +- apps/ios/Shared/Views/ChatPreviewView.swift | 2 +- apps/ios/Shared/Views/ChatView.swift | 9 ++- .../Shared/Views/Helpers/AddContactView.swift | 43 ++++++++++++ .../Shared/Views/Helpers/ChatHeaderView.swift | 56 ++++++++++++++-- .../Views/Helpers/ConnectContactView.swift | 54 +++++++++++++++ .../Views/Helpers/InviteContactView.swift | 21 ------ apps/ios/Shared/Views/Helpers/QRCode.swift | 50 ++++++++++++++ .../Shared/Views/Helpers/ScanQRCodeView.swift | 21 ------ .../ios/Shared/Views/Helpers/ShareSheet.swift | 40 +++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 65 ++++++++++++++---- .../xcshareddata/WorkspaceSettings.xcsettings | 8 +++ .../xcshareddata/swiftpm/Package.resolved | 16 +++++ 18 files changed, 390 insertions(+), 132 deletions(-) delete mode 100644 apps/ios/README.md create mode 100644 apps/ios/Shared/Views/Helpers/AddContactView.swift create mode 100644 apps/ios/Shared/Views/Helpers/ConnectContactView.swift delete mode 100644 apps/ios/Shared/Views/Helpers/InviteContactView.swift create mode 100644 apps/ios/Shared/Views/Helpers/QRCode.swift delete mode 100644 apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift create mode 100644 apps/ios/Shared/Views/Helpers/ShareSheet.swift create mode 100644 apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/apps/ios/README.md b/apps/ios/README.md deleted file mode 100644 index 3a637d7973..0000000000 --- a/apps/ios/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Setup for iOS - -## Prerequisites - -- Prepare folders: - - ```sh - mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim - ``` - -## Update binaries - -1. Extract binaries to `./apps/ios/Libraries/mac`. - -2. Prepare binaries: - - ```sh - chmod +w ./apps/ios/Libraries/mac/* - cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/ios - cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/sim - for f in ./apps/ios/Libraries/ios/*; do mac2ios $f; done | wc -l - for f in ./apps/ios/Libraries/sim/*; do mac2ios -s $f; done | wc -l - ``` - -3. Put binaries into `./apps/ios/Libraries`. - - ```sh - cp ./apps/ios/Libraries/sim/* ./apps/ios/Libraries - ``` - - or: - - ```sh - cp ./apps/ios/Libraries/ios/* ./apps/ios/Libraries - ``` diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index cd87d3e035..1e48953651 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -57,15 +57,15 @@ struct ChatPreview: Identifiable, Decodable { } enum ChatType: String { - case direct - case group + case direct = "@" + case group = "#" } enum ChatInfo: Identifiable, Codable { case direct(contact: Contact) case group(groupInfo: GroupInfo) - var displayName: String { + var localDisplayName: String { get { switch self { case let .direct(contact): return "@\(contact.localDisplayName)" @@ -216,6 +216,15 @@ enum MsgContent { } } + var cmdString: String { + get { + switch self { + case let .text(text): return "text \(text)" + default: return "" + } + } + } + enum CodingKeys: String, CodingKey { case type case text @@ -240,16 +249,3 @@ extension MsgContent: Decodable { } } } - -//func parseMsgContent(_ mc: SomeMsgContent) -> MsgContent { -// if let type = mc["type"] as? String { -// let text_ = mc["text"] as? String -// switch type { -// case "text": -// if let text = text_ { return .text(text) } -// case let t: -// return .unknown(type: t, text: text_ ?? "unknown item", json: prettyJSON(mc) ?? "error") -// } -// } -// return .invalid(json: prettyJSON(mc) ?? "error") -//} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index fe022d8d55..944c206c33 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -16,20 +16,27 @@ private let jsonEncoder = getJSONEncoder() enum ChatCommand { case apiGetChats - case apiGetChatItems(type: ChatType, id: Int64) + case apiGetChat(type: ChatType, id: Int64) + case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) + case addContact + case connect(connReq: String) case string(String) - case help var cmdString: String { get { switch self { case .apiGetChats: - return "/api/v1/chats" - case let .apiGetChatItems(type, id): - return "/api/v1/chat/\(type)/\(id)" + return "/get chats" + case let .apiGetChat(type, id): + return "/get chat \(type.rawValue)\(id)" + case let .apiSendMessage(type, id, mc): + return "/send msg \(type.rawValue)\(id) \(mc.cmdString)" + case .addContact: + return "/c" + case let .connect(connReq): + return "/c \(connReq)" case let .string(str): return str - case .help: return "/help" } } } @@ -42,7 +49,10 @@ struct APIResponse: Decodable { enum ChatResponse: Decodable, Error { case response(type: String, json: String) case apiChats(chats: [ChatPreview]) - case apiDirectChat(chat: Chat) // direct/ or group/, same as ChatPreview.id + case apiChat(chat: Chat) + case invitation(connReqInvitation: String) + case sentConfirmation + case sentInvitation // case newSentInvitation case contactConnected(contact: Contact) case newChatItem(chatItem: AChatItem) @@ -52,7 +62,10 @@ enum ChatResponse: Decodable, Error { switch self { case let .response(type, _): return "* \(type)" case .apiChats: return "apiChats" - case .apiDirectChat: return "apiDirectChat" + case .apiChat: return "apiChat" + case .invitation: return "invitation" + case .sentConfirmation: return "sentConfirmation" + case .sentInvitation: return "sentInvitation" case .contactConnected: return "contactConnected" case .newChatItem: return "newChatItem" } @@ -64,7 +77,10 @@ enum ChatResponse: Decodable, Error { switch self { case let .response(_, json): return json case let .apiChats(chats): return String(describing: chats) - case let .apiDirectChat(chat): return String(describing: chat) + case let .apiChat(chat): return String(describing: chat) + case let .invitation(connReqInvitation): return connReqInvitation + case .sentConfirmation: return "sentConfirmation: no details" + case .sentInvitation: return "sentInvitation: no details" case let .contactConnected(contact): return String(describing: contact) case let .newChatItem(chatItem): return String(describing: chatItem) } @@ -127,6 +143,7 @@ func chatCreateUser(_ p: Profile) -> User? { func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! + print("command", cmd.cmdString) // TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? // DispatchQueue.main.async { // termId += 1 @@ -141,16 +158,33 @@ func chatRecvMsg() throws -> ChatResponse { func apiGetChats() throws -> [ChatPreview] { let r = try chatSendCmd(.apiGetChats) - switch r { - case let .apiChats(chats): return chats - default: throw r - } + if case let .apiChats(chats) = r { return chats } + throw r } -func apiGetChatItems(type: ChatType, id: Int64) throws -> Chat { - let r = try chatSendCmd(.apiGetChatItems(type: type, id: id)) +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 } + 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 let .apiDirectChat(chat): return chat + case .sentConfirmation: return + case .sentInvitation: return default: throw r } } diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift index b46b3c1374..aa4740fee5 100644 --- a/apps/ios/Shared/MyPlayground.playground/Contents.swift +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -79,3 +79,4 @@ struct Test: Decodable { //jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!) +"\(ChatType.direct)" diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline index 7b48f2c7ec..19563d37f5 100644 --- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -3,7 +3,7 @@ version = "3.0"> diff --git a/apps/ios/Shared/Views/ChatListView.swift b/apps/ios/Shared/Views/ChatListView.swift index 5a27e2aa06..a29283ee69 100644 --- a/apps/ios/Shared/Views/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatListView.swift @@ -46,8 +46,9 @@ struct ChatListView: View { ChatView(chatInfo: chatPreview.chatInfo) .onAppear { do { - let chat = try apiGetChatItems(type: .direct, id: chatPreview.chatInfo.apiId) - chatModel.chats[chat.chatInfo.id] = chat + let ci = chatPreview.chatInfo + let chat = try apiGetChat(type: ci.chatType, id: ci.apiId) + chatModel.chats[ci.id] = chat } catch { print("apiGetChatItems", error) } diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift index 6dda250ba5..16a0cb8711 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -12,7 +12,7 @@ struct ChatPreviewView: View { var chatPreview: ChatPreview var body: some View { - Text(chatPreview.chatInfo.displayName) + Text(chatPreview.chatInfo.localDisplayName) } } diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/ChatView.swift index 9e4a040057..419fef2f79 100644 --- a/apps/ios/Shared/Views/ChatView.swift +++ b/apps/ios/Shared/Views/ChatView.swift @@ -36,7 +36,14 @@ struct ChatView: View { } func sendMessage(_ msg: String) { - + do { + let chatItem = try apiSendMessage(type: chatInfo.chatType, id: chatInfo.apiId, msg: .text(msg)) + let chat = chatModel.chats[chatInfo.id] ?? Chat(chatInfo: chatInfo, chatItems: []) + chatModel.chats[chatInfo.id] = chat + chat.chatItems.append(chatItem) + } catch { + print(error) + } } } diff --git a/apps/ios/Shared/Views/Helpers/AddContactView.swift b/apps/ios/Shared/Views/Helpers/AddContactView.swift new file mode 100644 index 0000000000..fc23891112 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/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/Helpers/ChatHeaderView.swift b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift index d2cf7fa70e..f618be8d25 100644 --- a/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift @@ -10,8 +10,13 @@ import SwiftUI struct ChatHeaderView: View { @State private var showAddChat = false - @State private var inviteContact = false - @State private var scanQRCode = 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 { @@ -24,15 +29,54 @@ struct ChatHeaderView: View { Image(systemName: "square.and.pencil") } .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { - Button("Invite contact") { inviteContact = true } - Button("Scan QR code") { scanQRCode = true } + Button("Add contact") { addContactAction() } + Button("Scan QR code") { connectContact = true } Button("Create group") { createGroup = true } } - .sheet(isPresented: $inviteContact, content: { InviteContactView() }) - .sheet(isPresented: $scanQRCode, content: { ScanQRCodeView() }) + .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() }) } .padding(.horizontal) + .padding(.top) + } + + func addContactAction() { + do { + connReqInvitation = try apiAddContact() + addContact = true + } catch { + addContactAlert = true + addContactError = error + print(error) + } + } + + 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 ?? "") + ) } } diff --git a/apps/ios/Shared/Views/Helpers/ConnectContactView.swift b/apps/ios/Shared/Views/Helpers/ConnectContactView.swift new file mode 100644 index 0000000000..088f336f9c --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/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 { + print(error) + completed(error) + } + case let .failure(e): + print(e) + completed(e) + } + } +} + +struct ConnectContactView_Previews: PreviewProvider { + static var previews: some View { + return ConnectContactView(completed: {_ in }) + } +} diff --git a/apps/ios/Shared/Views/Helpers/InviteContactView.swift b/apps/ios/Shared/Views/Helpers/InviteContactView.swift deleted file mode 100644 index 0c6355815a..0000000000 --- a/apps/ios/Shared/Views/Helpers/InviteContactView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// InviteContactView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 29/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct InviteContactView: View { - var body: some View { - Text("InviteContactView") - } -} - -struct InviteContactView_Previews: PreviewProvider { - static var previews: some View { - InviteContactView() - } -} diff --git a/apps/ios/Shared/Views/Helpers/QRCode.swift b/apps/ios/Shared/Views/Helpers/QRCode.swift new file mode 100644 index 0000000000..b92dc44fd3 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/QRCode.swift @@ -0,0 +1,50 @@ +// +// 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) + } + } + .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/Helpers/ScanQRCodeView.swift b/apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift deleted file mode 100644 index 1a21463ba3..0000000000 --- a/apps/ios/Shared/Views/Helpers/ScanQRCodeView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ScanQRCodeView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 29/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct ScanQRCodeView: View { - var body: some View { - Text("ScanQRCodeView") - } -} - -struct ScanQRCodeView_Previews: PreviewProvider { - static var previews: some View { - ScanQRCodeView() - } -} diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift new file mode 100644 index 0000000000..3b9dbcb5e1 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/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/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5a68218041..79d2bbc2f2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 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 */; }; 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 */; }; @@ -53,12 +54,16 @@ 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.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 */; }; 5CCD403127A5F1C600368C90 /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */; }; 5CCD403227A5F1C600368C90 /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */; }; - 5CCD403427A5F6DF00368C90 /* InviteContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */; }; - 5CCD403527A5F6DF00368C90 /* InviteContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */; }; - 5CCD403727A5F9A200368C90 /* ScanQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */; }; - 5CCD403827A5F9A200368C90 /* ScanQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanQRCodeView.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 */; }; /* End PBXBuildFile section */ @@ -113,9 +118,11 @@ 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 = ""; }; 5CA05A4E279752D00002BEB4 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.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 = ""; }; 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHeaderView.swift; sourceTree = ""; }; - 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteContactView.swift; sourceTree = ""; }; - 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeView.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 = ""; }; /* End PBXFileReference section */ @@ -124,6 +131,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */, @@ -184,9 +192,11 @@ 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5CA05A4E279752D00002BEB4 /* MessageView.swift */, 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */, + 5CCD403327A5F6DF00368C90 /* AddContactView.swift */, + 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */, 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, - 5CCD403627A5F9A200368C90 /* ScanQRCodeView.swift */, - 5CCD403327A5F6DF00368C90 /* InviteContactView.swift */, + 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, + 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, ); path = Helpers; sourceTree = ""; @@ -303,6 +313,9 @@ dependencies = ( ); name = "SimpleX (iOS)"; + packageProductDependencies = ( + 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */, + ); productName = "SimpleX (iOS)"; productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; productType = "com.apple.product-type.application"; @@ -398,6 +411,9 @@ Base, ); mainGroup = 5CA059BD279559F40002BEB4; + packageReferences = ( + 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */, + ); productRefGroup = 5CA059CB279559F40002BEB4 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -453,17 +469,19 @@ 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, 5CCD403127A5F1C600368C90 /* ChatHeaderView.swift in Sources */, 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, - 5CCD403427A5F6DF00368C90 /* InviteContactView.swift in Sources */, + 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, - 5CCD403727A5F9A200368C90 /* ScanQRCodeView.swift in Sources */, + 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, + 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -477,17 +495,19 @@ 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, + 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */, 5CCD403227A5F1C600368C90 /* ChatHeaderView.swift in Sources */, 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, - 5CCD403527A5F6DF00368C90 /* InviteContactView.swift in Sources */, + 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, - 5CCD403827A5F9A200368C90 /* ScanQRCodeView.swift in Sources */, + 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */, 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, + 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -649,6 +669,7 @@ ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "$(PRODUCT_NAME) 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; @@ -689,6 +710,7 @@ ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "$(PRODUCT_NAME) 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; @@ -921,6 +943,25 @@ 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/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 +} From e29ea99d2cb99ea93fd0d0bcd39a4a0cb414e18f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 30 Jan 2022 21:51:23 +0000 Subject: [PATCH 28/82] getChats returns [Chat] with 0-1 item instead of [ChatPreview] (#240) --- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Messages.hs | 23 ------------------- src/Simplex/Chat/Store.hs | 40 +++++++++++++++++----------------- 3 files changed, 21 insertions(+), 44 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9dd0aac971..13eb6f2424 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -117,7 +117,7 @@ data ChatCommand deriving (Show) data ChatResponse - = CRApiChats {chats :: [AChatPreview]} + = CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} | CRNewChatItem {chatItem :: AChatItem} | CRCmdAccepted {corr :: CorrId} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index c7b26de99f..c4780ea34e 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -146,13 +146,6 @@ instance ToJSON (Chat c) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions -data ChatPreview c = ChatPreview {chatInfo :: ChatInfo c, lastChatItem :: Maybe (CChatItem c)} - deriving (Show, Generic) - -instance ToJSON (ChatPreview 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 @@ -161,22 +154,6 @@ instance ToJSON AChat where toJSON (AChat _ c) = J.toJSON c toEncoding (AChat _ c) = J.toEncoding c --- | type to show the list of chats, with one last message in each -data AChatPreview = forall c. AChatPreview (SChatType c) (ChatInfo c) (Maybe (CChatItem c)) - -deriving instance Show AChatPreview - -instance ToJSON AChatPreview where - toJSON (AChatPreview _ chat ccItem_) = J.toJSON $ JSONAnyChatPreview chat ccItem_ - toEncoding (AChatPreview _ chat ccItem_) = J.toEncoding $ J.toJSON $ JSONAnyChatPreview chat ccItem_ - -data JSONAnyChatPreview c d = JSONAnyChatPreview {chatInfo :: ChatInfo c, chatItem :: Maybe (CChatItem c)} - deriving (Generic) - -instance ToJSON (JSONAnyChatPreview c d) 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) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index f867744eed..e7f4db1611 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1823,18 +1823,18 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId) -getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChatPreview] +getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChat] getChatPreviews st user = liftIO . withTransaction st $ \db -> do directChatPreviews <- getDirectChatPreviews_ db user groupChatPreviews <- getGroupChatPreviews_ db user pure $ sortOn (Down . ts) (directChatPreviews <> groupChatPreviews) where - ts :: AChatPreview -> UTCTime - ts (AChatPreview _ _ Nothing) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo createdAt - ts (AChatPreview _ _ (Just (CChatItem _ (ChatItem _ CIMeta {itemTs} _)))) = itemTs + ts :: AChat -> UTCTime + ts (AChat _ (Chat _ [])) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo createdAt + ts (AChat _ (Chat _ (CChatItem _ (ChatItem _ CIMeta {itemTs} _) : _))) = itemTs -getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] +getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] getDirectChatPreviews_ db User {userId} = do tz <- getCurrentTimeZone map (toDirectChatPreview tz) @@ -1865,13 +1865,13 @@ getDirectChatPreviews_ db User {userId} = do |] (Only userId) where - toDirectChatPreview :: TimeZone -> ContactRow :. MaybeChatItemRow -> AChatPreview + toDirectChatPreview :: TimeZone -> ContactRow :. MaybeChatItemRow -> AChat toDirectChatPreview tz (contactRow :. ciRow_) = let contact = toContact' contactRow - ci_ = toMaybeDirectChatItem tz ciRow_ - in AChatPreview SCTDirect (DirectChat contact) ci_ + ci_ = toDirectChatItemList tz ciRow_ + in AChat SCTDirect $ Chat (DirectChat contact) ci_ -getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChatPreview] +getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do tz <- getCurrentTimeZone map (toGroupChatPreview tz) @@ -1910,12 +1910,12 @@ getGroupChatPreviews_ db User {userId, userContactId} = do |] (userId, userContactId) where - toGroupChatPreview :: TimeZone -> GroupInfoRow :. MaybeGroupChatItemRow -> AChatPreview + toGroupChatPreview :: TimeZone -> GroupInfoRow :. MaybeGroupChatItemRow -> AChat toGroupChatPreview tz (((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. ciRow_) = let membership = toGroupMember userContactId userMemberRow groupInfo = GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} - ci_ = toMaybeGroupChatItem tz userContactId ciRow_ - in AChatPreview SCTGroup (GroupChat groupInfo) ci_ + ci_ = toGroupChatItemList tz userContactId ciRow_ + in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ getDirectChat :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m (Chat 'CTDirect) getDirectChat st userId contactId = @@ -2051,10 +2051,10 @@ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) = ACIContent d@SMDSnd ciContent -> CChatItem d $ ChatItem CIDirectSnd ciMeta ciContent ACIContent d@SMDRcv ciContent -> CChatItem d $ ChatItem CIDirectRcv ciMeta ciContent -toMaybeDirectChatItem :: TimeZone -> MaybeChatItemRow -> Maybe (CChatItem 'CTDirect) -toMaybeDirectChatItem tz (Just itemId, Just itemTs, Just itemContent, Just itemText, Just createdAt) = - Just $ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) -toMaybeDirectChatItem _ _ = Nothing +toDirectChatItemList :: TimeZone -> MaybeChatItemRow -> [CChatItem 'CTDirect] +toDirectChatItemList tz (Just itemId, Just itemTs, Just itemContent, Just itemText, Just createdAt) = + [toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt)] +toDirectChatItemList _ _ = [] type GroupChatItemRow = ChatItemRow :. MaybeGroupMemberRow @@ -2069,10 +2069,10 @@ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, create (ACIContent d@SMDRcv ciContent, Just member) -> Right $ CChatItem d (ChatItem (CIGroupRcv member) ciMeta ciContent) _ -> Left $ SEBadChatItem itemId -toMaybeGroupChatItem :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> Maybe (CChatItem 'CTGroup) -toMaybeGroupChatItem tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just createdAt) :. memberRow_) = - eitherToMaybe $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, createdAt) :. memberRow_) -toMaybeGroupChatItem _ _ _ = Nothing +toGroupChatItemList :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] +toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just createdAt) :. memberRow_) = + either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, createdAt) :. memberRow_) +toGroupChatItemList _ _ _ = [] -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. From 945ed3f7cb1f23073a8a01133f528cead56d7b4a Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 31 Jan 2022 13:20:26 +0400 Subject: [PATCH 29/82] fix queries returning duplicate contacts (#242) --- src/Simplex/Chat/Store.hs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index e7f4db1611..c26acf4603 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1861,9 +1861,16 @@ getDirectChatPreviews_ db User {userId} = do LEFT JOIN chat_items ci ON ci.contact_id = CIMaxDates.contact_id AND ci.item_ts = CIMaxDates.MaxDate WHERE ct.user_id = ? + AND c.connection_id IN ( + SELECT cc.connection_id + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id AND (cc.conn_status = ? OR cc.conn_status = ?) + ORDER BY cc.connection_id DESC + LIMIT 1 + ) ORDER BY ci.item_ts DESC |] - (Only userId) + (userId, ConnReady, ConnSndReady) where toDirectChatPreview :: TimeZone -> ContactRow :. MaybeChatItemRow -> AChat toDirectChatPreview tz (contactRow :. ciRow_) = @@ -1956,10 +1963,12 @@ getContact_ db userId contactId = 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 - JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.contact_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) + (userId, contactId, ConnReady, ConnSndReady) ) getDirectChatItems_ :: DB.Connection -> UserId -> Int64 -> IO [CChatItem 'CTDirect] From 047aa7deef143a393014fd8c682ea79e0d278589 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 31 Jan 2022 15:14:56 +0400 Subject: [PATCH 30/82] delete contact api (#243) * delete contact api * chat command --- src/Simplex/Chat.hs | 27 +++++++++++------- src/Simplex/Chat/Controller.hs | 5 ++-- src/Simplex/Chat/Store.hs | 52 +++++++++++++--------------------- src/Simplex/Chat/View.hs | 4 +-- 4 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index fe133fd6a1..998c825e51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -141,6 +141,18 @@ processChatCommand user@User {userId, profile} = \case ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) setActive $ ActiveG gName pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + APIDeleteContact contactId -> do + ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId contactId + withStore (\st -> getContactGroupNames st userId ct) >>= \case + [] -> do + conns <- withStore $ \st -> getContactConnections st userId ct + 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 ChatHelp section -> pure $ CRChatHelp section Welcome -> pure $ CRWelcome user AddContact -> procCmd $ do @@ -157,17 +169,9 @@ processChatCommand user@User {userId, profile} = \case ConnectAdmin -> procCmd $ do connect adminContactReq $ XContact profile Nothing pure CRSentInvitation - DeleteContact cName -> - withStore (\st -> getContactGroupNames st userId cName) >>= \case - [] -> do - conns <- withStore $ \st -> getContactConnections st userId cName - procCmd $ do - withAgent $ \a -> forM_ conns $ \conn -> - deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () - withStore $ \st -> deleteContact st userId cName - unsetActive $ ActiveC cName - pure $ CRContactDeleted cName - gs -> throwChatError $ CEContactGroups cName gs + DeleteContact cName -> do + contactId <- withStore $ \st -> getContactIdByName st userId cName + processChatCommand user $ APIDeleteContact contactId ListContacts -> CRContactsList <$> withStore (`getUserContacts` user) CreateMyAddress -> procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) @@ -1307,6 +1311,7 @@ chatCommandP = <|> "/get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal) <|> "/get chatItems count=" *> (APIGetChatItems <$> A.decimal) <|> "/send msg " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) + <|> "/_del @" *> (APIDeleteContact <$> A.decimal) <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 13eb6f2424..e413ab0a09 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -82,6 +82,7 @@ data ChatCommand | APIGetChat ChatType Int64 | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent + | APIDeleteContact Int64 | ChatHelp HelpSection | Welcome | AddContact @@ -141,7 +142,7 @@ data ChatResponse | CRSentInvitation | CRContactUpdated {fromContact :: Contact, toContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} - | CRContactDeleted {contactName :: ContactName} -- TODO + | CRContactDeleted {contact :: Contact} | CRUserContactLinkCreated {connReqContact :: ConnReqContact} | CRUserContactLinkDeleted | CRReceivedContactRequest {contactName :: ContactName, profile :: Profile} -- TODO what is the entity here? @@ -207,7 +208,7 @@ instance ToJSON ChatError where data ChatErrorType = CEGroupUserRole | CEInvalidConnReq - | CEContactGroups {contactName :: ContactName, groupNames :: [GroupName]} + | CEContactGroups {contact :: Contact, groupNames :: [GroupName]} | CEGroupContactRole {contactName :: ContactName} | CEGroupDuplicateMember {contactName :: ContactName} | CEGroupDuplicateMemberId diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index c26acf4603..206a81ff1f 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -268,8 +268,8 @@ createContact_ db userId connId Profile {displayName, fullName} viaGroup = DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, 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 @@ -278,38 +278,26 @@ 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) updateUserProfile :: StoreMonad m => SQLiteStore -> User -> Profile -> m () updateUserProfile st User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} @@ -594,24 +582,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 $ SEContactNotFoundByName 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) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7af42cd1ae..43df867b31 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -60,7 +60,7 @@ responseToView cmd = \case CRInvitation cReq -> r' $ viewConnReqInvitation cReq CRSentConfirmation -> r' ["confirmation sent!"] CRSentInvitation -> r' ["connection request sent!"] - CRContactDeleted c -> r' [ttyContact c <> ": contact is deleted"] + CRContactDeleted Contact {localDisplayName} -> r' [ttyContact localDisplayName <> ": contact is deleted"] CRAcceptingContactRequest c -> r' [ttyContact c <> ": accepting contact request..."] CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted @@ -445,7 +445,7 @@ viewChatError :: ChatError -> [StyledString] viewChatError = \case ChatError err -> case err of CEInvalidConnReq -> viewInvalidConnReq - CEContactGroups c gNames -> [ttyContact c <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames] + 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"] From 0a18985e68b0b22ada696ae8546e2321489b8bbb Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 31 Jan 2022 21:53:53 +0400 Subject: [PATCH 31/82] contact requests api (#244) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 70 +++++++++++++++++------------ src/Simplex/Chat/Controller.hs | 9 ++-- src/Simplex/Chat/Messages.hs | 6 ++- src/Simplex/Chat/Store.hs | 81 +++++++++++++++++++++++----------- src/Simplex/Chat/Types.hs | 31 +++++++++++-- src/Simplex/Chat/View.hs | 8 ++-- 6 files changed, 137 insertions(+), 68 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 998c825e51..abf01ceee7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -128,6 +128,7 @@ processChatCommand user@User {userId, profile} = \case APIGetChat cType cId -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st userId cId) CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId) + CTContactRequest -> pure $ CRChatError ChatErrorNotImplemented APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented APISendMessage cType chatId mc -> case cType of CTDirect -> do @@ -141,18 +142,35 @@ processChatCommand user@User {userId, profile} = \case ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) setActive $ ActiveG gName pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - APIDeleteContact contactId -> do - ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId contactId - withStore (\st -> getContactGroupNames st userId ct) >>= \case - [] -> do - conns <- withStore $ \st -> getContactConnections st userId ct - 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 + CTContactRequest -> pure $ CRChatError ChatErrorNotImplemented + APIDeleteChat cType chatId -> 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 + 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 $ CRChatCmdError ChatErrorNotImplemented + CTContactRequest -> do + cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- + withStore $ \st -> + getContactRequest st userId chatId + `E.finally` deleteContactRequest st userId chatId + withAgent $ \a -> rejectContact a connId invId + pure $ CRContactRequestRejected cReq + APIAcceptContact contactRequestId -> do + ctReq@UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId} <- withStore $ \st -> + getContactRequest st userId contactRequestId + procCmd $ do + connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile + withStore $ \st -> createAcceptedContact st userId connId cName profileId + pure $ CRAcceptingContactRequest ctReq ChatHelp section -> pure $ CRChatHelp section Welcome -> pure $ CRWelcome user AddContact -> procCmd $ do @@ -171,7 +189,7 @@ processChatCommand user@User {userId, profile} = \case pure CRSentInvitation DeleteContact cName -> do contactId <- withStore $ \st -> getContactIdByName st userId cName - processChatCommand user $ APIDeleteContact contactId + processChatCommand user $ APIDeleteChat CTDirect contactId ListContacts -> CRContactsList <$> withStore (`getUserContacts` user) CreateMyAddress -> procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) @@ -186,18 +204,11 @@ processChatCommand user@User {userId, profile} = \case pure CRUserContactLinkDeleted ShowMyAddress -> CRUserContactLink <$> withStore (`getUserContactLink` userId) AcceptContact cName -> do - UserContactRequest {agentInvitationId, profileId} <- withStore $ \st -> - getContactRequest st userId cName - procCmd $ do - connId <- withAgent $ \a -> acceptContact a agentInvitationId . directMessage $ XInfo profile - withStore $ \st -> createAcceptedContact st userId connId cName profileId - pure $ CRAcceptingContactRequest cName + contactRequestId <- withStore $ \st -> getContactRequestIdByName st userId cName + processChatCommand user $ APIAcceptContact contactRequestId RejectContact cName -> do - UserContactRequest {agentContactConnId, agentInvitationId} <- withStore $ \st -> - getContactRequest st userId cName - `E.finally` deleteContactRequest st userId cName - withAgent $ \a -> rejectContact a agentContactConnId agentInvitationId - pure $ CRContactRequestRejected cName + contactRequestId <- withStore $ \st -> getContactRequestIdByName st userId cName + processChatCommand user $ APIDeleteChat CTContactRequest contactRequestId SendMessage cName msg -> do contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg @@ -768,9 +779,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 - toView $ CRReceivedContactRequest 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 = @@ -1311,7 +1322,8 @@ chatCommandP = <|> "/get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal) <|> "/get chatItems count=" *> (APIGetChatItems <$> A.decimal) <|> "/send msg " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) - <|> "/_del @" *> (APIDeleteContact <$> A.decimal) + <|> "/_del " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) + <|> "/_ac " *> (APIAcceptContact <$> A.decimal) <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress @@ -1348,7 +1360,7 @@ chatCommandP = <|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat <|> ("/version" <|> "/v") $> ShowVersion where - chatTypeP = "@" $> CTDirect <|> "#" $> CTGroup + chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> "<@" $> CTContactRequest msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e413ab0a09..849c5f2883 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -82,7 +82,8 @@ data ChatCommand | APIGetChat ChatType Int64 | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent - | APIDeleteContact Int64 + | APIDeleteChat ChatType Int64 + | APIAcceptContact Int64 | ChatHelp HelpSection | Welcome | AddContact @@ -128,7 +129,7 @@ data ChatResponse | CRGroupMembers {group :: Group} | CRContactsList {contacts :: [Contact]} | CRUserContactLink {connReqContact :: ConnReqContact} - | CRContactRequestRejected {contactName :: ContactName} -- TODO + | CRContactRequestRejected {contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {groupInfo :: GroupInfo} | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} | CRGroupsList {groups :: [GroupInfo]} @@ -145,8 +146,8 @@ data ChatResponse | CRContactDeleted {contact :: Contact} | CRUserContactLinkCreated {connReqContact :: ConnReqContact} | CRUserContactLinkDeleted - | CRReceivedContactRequest {contactName :: ContactName, profile :: Profile} -- TODO what is the entity here? - | CRAcceptingContactRequest {contactName :: ContactName} -- TODO + | CRReceivedContactRequest {contactRequest :: UserContactRequest} + | CRAcceptingContactRequest {contactRequest :: UserContactRequest} | CRLeftMemberUser {groupInfo :: GroupInfo} | CRGroupDeletedUser {groupInfo :: GroupInfo} | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index c4780ea34e..58f8ddf77a 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -36,7 +36,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) -data ChatType = CTDirect | CTGroup +data ChatType = CTDirect | CTGroup | CTContactRequest deriving (Show, Generic) instance ToJSON ChatType where @@ -46,12 +46,14 @@ instance ToJSON ChatType where 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} + | JCIInfoContactRequest {contactRequest :: UserContactRequest} deriving (Generic) instance ToJSON JSONChatInfo where @@ -66,6 +68,7 @@ jsonChatInfo :: ChatInfo c -> JSONChatInfo jsonChatInfo = \case DirectChat c -> JCInfoDirect c GroupChat g -> JCInfoGroup g + ContactRequest g -> JCIInfoContactRequest g data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, @@ -250,6 +253,7 @@ aciContentJSON = \case data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect SCTGroup :: SChatType 'CTGroup + SCTContactRequest :: SChatType 'CTContactRequest deriving instance Show (SChatType c) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 206a81ff1f..3d10982fa4 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -39,6 +39,7 @@ module Simplex.Chat.Store getUserContactLink, createContactRequest, getContactRequest, + getContactRequestIdByName, deleteContactRequest, createAcceptedContact, getLiveSndFileTransfers, @@ -468,10 +469,12 @@ 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 + join <$> withLocalDisplayName db userId displayName (createContactRequest' db) + where + createContactRequest' db ldn = do DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) profileId <- insertedRowId db DB.execute @@ -481,33 +484,58 @@ createContactRequest st userId userContactId invId Profile {displayName, fullNam (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id) VALUES (?,?,?,?,?) |] (userContactId, invId, profileId, ldn, userId) - pure ldn + 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 + runExceptT $ + ExceptT $ 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 = + contactReq + <$> DB.query + db + [sql| + SELECT + 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 + 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) + where + contactReq :: [(ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text)] -> Either StoreError UserContactRequest + contactReq [(localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName)] = do + let profile = Profile {displayName, fullName} + Right UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile} + contactReq _ = Left $ SEContactRequestNotFound contactRequestId + +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 "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) + 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 -> m () createAcceptedContact st userId agentConnId localDisplayName profileId = @@ -2131,7 +2159,8 @@ data StoreError | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink | SEUserContactLinkNotFound - | SEContactRequestNotFound {contactName :: ContactName} + | SEContactRequestNotFound {contactRequestId :: Int64} + | SEContactRequestNotFoundByName {contactName :: ContactName} | SEGroupNotFound {groupId :: Int64} | SEGroupNotFoundByName {groupName :: GroupName} | SEGroupWithoutUser diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d6ab2a2399..fe80668468 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -93,13 +93,17 @@ 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 } - deriving (Eq, Show) + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON UserContactRequest where + toEncoding = J.genericToEncoding J.defaultOptions type ContactName = Text @@ -517,6 +521,25 @@ 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) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 43df867b31..230253a44c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -47,7 +47,7 @@ responseToView cmd = \case CRWelcome user -> r $ chatWelcome user CRContactsList cs -> r $ viewContactsList cs CRUserContactLink cReq -> r $ connReqContact_ "Your chat address:" cReq - CRContactRequestRejected c -> r [ttyContact c <> ": contact request rejected"] + 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 @@ -61,7 +61,7 @@ responseToView cmd = \case CRSentConfirmation -> r' ["confirmation sent!"] CRSentInvitation -> r' ["connection request sent!"] CRContactDeleted Contact {localDisplayName} -> r' [ttyContact localDisplayName <> ": contact is deleted"] - CRAcceptingContactRequest c -> r' [ttyContact c <> ": accepting contact request..."] + CRAcceptingContactRequest UserContactRequest {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..."] @@ -76,7 +76,7 @@ responseToView cmd = \case CRUserProfileUpdated p p' -> r' $ viewUserProfileUpdated p p' CRContactUpdated c c' -> viewContactUpdated c c' CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt - CRReceivedContactRequest c p -> viewReceivedContactRequest c p + CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile CRRcvFileStart ft -> receivingFile_ "started" ft CRRcvFileComplete ft -> receivingFile_ "completed" ft CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft @@ -479,7 +479,7 @@ viewChatError = \case 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] e -> ["chat db error: " <> sShow e] ChatErrorAgent err -> case err of SMP SMP.AUTH -> ["error: this connection is deleted"] From 6d5b5ab44f1e2a0aa7a6e39bcd0e0c3691e8a267 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 31 Jan 2022 22:43:39 +0400 Subject: [PATCH 32/82] getContactRequestChatPreviews_ (#245) --- src/Simplex/Chat/Store.hs | 78 +++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 3d10982fa4..f55fdb122f 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -490,17 +490,16 @@ createContactRequest st userId userContactId invId Profile {displayName, fullNam getContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m UserContactRequest getContactRequest st userId contactRequestId = liftIOEither . withTransaction st $ \db -> - runExceptT $ - ExceptT $ getContactRequest_ db userId contactRequestId + getContactRequest_ db userId contactRequestId getContactRequest_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError UserContactRequest) getContactRequest_ db userId contactRequestId = - contactReq - <$> DB.query + firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ + DB.query db [sql| SELECT - cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, + 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 FROM contact_requests cr JOIN connections c USING (user_contact_link_id) @@ -509,12 +508,13 @@ getContactRequest_ db userId contactRequestId = AND cr.contact_request_id = ? |] (userId, contactRequestId) - where - contactReq :: [(ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text)] -> Either StoreError UserContactRequest - contactReq [(localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName)] = do - let profile = Profile {displayName, fullName} - Right UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile} - contactReq _ = Left $ SEContactRequestNotFound contactRequestId + +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text) + +toContactRequest :: ContactRequestRow -> UserContactRequest +toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName) = do + let profile = Profile {displayName, fullName} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile} getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 getContactRequestIdByName st userId cName = @@ -842,12 +842,10 @@ getConnectionEntity st User {userId, userContactId} agentConnId = |] (groupMemberId, userId, userContactId) toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) - toGroupAndMember c (((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. memberRow) = - let member = toGroupMember userContactId memberRow - membership = toGroupMember userContactId userMemberRow - in ( GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership}, - (member :: GroupMember) {activeConn = Just c} - ) + 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 $ @@ -1378,13 +1376,10 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = (userId, contactId, userContactId) where toGroupAndMember :: [GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow] -> Maybe (GroupInfo, GroupMember) - toGroupAndMember [((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. memberRow :. connRow] = - let member = toGroupMember userContactId memberRow - membership = toGroupMember userContactId userMemberRow - in Just - ( GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership}, - (member :: GroupMember) {activeConn = toMaybeConnection connRow} - ) + 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) @@ -1840,12 +1835,13 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChat] getChatPreviews st user = liftIO . withTransaction st $ \db -> do - directChatPreviews <- getDirectChatPreviews_ db user - groupChatPreviews <- getGroupChatPreviews_ db user - pure $ sortOn (Down . ts) (directChatPreviews <> groupChatPreviews) + 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 _ [])) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo createdAt + ts (AChat _ (Chat _ [])) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo/ContactRequest createdAt ts (AChat _ (Chat _ (CChatItem _ (ChatItem _ CIMeta {itemTs} _) : _))) = itemTs getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] @@ -1932,12 +1928,32 @@ getGroupChatPreviews_ db User {userId, userContactId} = do (userId, userContactId) where toGroupChatPreview :: TimeZone -> GroupInfoRow :. MaybeGroupChatItemRow -> AChat - toGroupChatPreview tz (((groupId, localDisplayName, displayName, fullName) :. userMemberRow) :. ciRow_) = - let membership = toGroupMember userContactId userMemberRow - groupInfo = GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} + toGroupChatPreview tz (groupInfoRow :. ciRow_) = + let groupInfo = toGroupInfo userContactId groupInfoRow ci_ = toGroupChatItemList tz userContactId ciRow_ in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ +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 + 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 + in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] + getDirectChat :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m (Chat 'CTDirect) getDirectChat st userId contactId = liftIOEither . withTransaction st $ \db -> runExceptT $ do From 53040dbe1d96bb3c01e0c8c727c5e4f7f4892a79 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 31 Jan 2022 21:28:07 +0000 Subject: [PATCH 33/82] iOS: chats and messages layout (#241) * iOS: chats and messages layout * model update for updated API * improve chat list view * chat view layouts * delete contacts * larger headers, clean up, move message reception loop to ContentView * settings: user profile --- apps/ios/Shared/ContentView.swift | 91 ++------------ apps/ios/Shared/Model/ChatModel.swift | 54 ++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 41 +++++-- apps/ios/Shared/Views/ChatListView.swift | 115 ++++++++++++------ apps/ios/Shared/Views/ChatPreviewView.swift | 46 ++++++- apps/ios/Shared/Views/ChatView.swift | 22 +++- .../Shared/Views/Helpers/ChatHeaderView.swift | 96 +++++---------- .../Shared/Views/Helpers/ChatItemView.swift | 63 ++++++++++ .../{ => NewChat}/AddContactView.swift | 0 .../{ => NewChat}/ConnectContactView.swift | 0 .../{ => NewChat}/CreateGroupView.swift | 0 .../Views/Helpers/NewChat/NewChatButton.swift | 80 ++++++++++++ .../Views/Helpers/{ => NewChat}/QRCode.swift | 0 .../Helpers/{ => NewChat}/ShareSheet.swift | 0 .../Views/Helpers/SendMessageView.swift | 11 +- .../Helpers/Settings/ProfileHeader.swift | 21 ++++ .../Helpers/UserSettings/SettingsButton.swift | 28 +++++ .../UserSettings/SettingsProfile.swift | 86 +++++++++++++ .../Helpers/UserSettings/SettingsView.swift | 27 ++++ .../Helpers/UserSettings/UserAddress.swift | 21 ++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 62 +++++++++- 21 files changed, 643 insertions(+), 221 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/ChatItemView.swift rename apps/ios/Shared/Views/Helpers/{ => NewChat}/AddContactView.swift (100%) rename apps/ios/Shared/Views/Helpers/{ => NewChat}/ConnectContactView.swift (100%) rename apps/ios/Shared/Views/Helpers/{ => NewChat}/CreateGroupView.swift (100%) create mode 100644 apps/ios/Shared/Views/Helpers/NewChat/NewChatButton.swift rename apps/ios/Shared/Views/Helpers/{ => NewChat}/QRCode.swift (100%) rename apps/ios/Shared/Views/Helpers/{ => NewChat}/ShareSheet.swift (100%) create mode 100644 apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift create mode 100644 apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift create mode 100644 apps/ios/Shared/Views/Helpers/UserSettings/SettingsProfile.swift create mode 100644 apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift create mode 100644 apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 5a107ce5a5..45298422ea 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -5,73 +5,25 @@ // Created by Evgeny Poberezkin on 17/01/2022. // -//import SwiftUI - -//struct ContentView: View { -// @State var messages: [String] = ["Start session:"] -// @State var text: String = "" -// -// func sendMessage() { -// } -// -// var body: some View { -// VStack { -// ScrollView { -// LazyVStack { -// ForEach(messages, id: \.self) { msg in -// MessageView(message: msg, sent: false) -// } -// } -// .padding(10) -// } -// .frame(minWidth: 0, -// maxWidth: .infinity, -// minHeight: 0, -// maxHeight: .infinity, -// alignment: .topLeading) -// HStack { -// TextField("Message...", text: $text) -// .textFieldStyle(RoundedBorderTextFieldStyle()) -// .frame(minHeight: CGFloat(30)) -// Button(action: sendMessage) { -// Text("Send") -// }.disabled(text.isEmpty) -// } -// .frame(minHeight: CGFloat(30)) -// .padding() -// } -// } -//} - import SwiftUI struct ContentView: View { @EnvironmentObject var chatModel: ChatModel -// var chatStore: chat_store -// private let controller: chat_controller - -// init(chatStore: chat_store) { -// self.chatStore = chatStore -// } - - -// @State private var logbuffer = [String]() -// @State private var chatcmd: String = "" -// @State private var chatlog: String = "" -// @FocusState private var focused: Bool -// -// func addLine(line: String) { -// print(line) -// logbuffer.append(line) -// if(logbuffer.count > 50) { _ = logbuffer.dropFirst() } -// chatlog = logbuffer.joined(separator: "\n") -// } - var body: some View { if let user = chatModel.currentUser { ChatListView(user: user) .onAppear { + DispatchQueue.global().async { + while(true) { + do { + try processReceivedMsg(chatModel, chatRecvMsg()) + } catch { + print("error receiving message: ", error) + } + } + } + do { let chats = try apiGetChats() chatModel.chatPreviews = chats @@ -82,29 +34,6 @@ struct ContentView: View { } else { WelcomeView() } - -// return VStack { -// ScrollView { -// VStack(alignment: .leading) { -// HStack { Spacer() } -// Text(chatlog) -// .lineLimit(nil) -// .font(.system(.body, design: .monospaced)) -// } -// .frame(maxWidth: .infinity) -// } -// -// TextField("Chat command", text: $chatcmd) -// .focused($focused) -// .onSubmit { -// print(chatcmd) -// var cCmd = chatcmd.cString(using: .utf8)! -// print(String.init(cString: chat_send_cmd(controller, &cCmd))) -// } -// .textInputAutocapitalization(.never) -// .disableAutocorrection(true) -// .padding() -// } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1e48953651..5a866bc617 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -8,21 +8,30 @@ import Foundation import Combine +import SwiftUI final class ChatModel: ObservableObject { @Published var currentUser: User? @Published var chats: Dictionary = [:] - @Published var chatPreviews: [ChatPreview] = [] + @Published var chatPreviews: [Chat] = [] @Published var chatItems: [ChatItem] = [] @Published var terminalItems: [TerminalItem] = [] } -struct User: Codable { +class User: Codable { 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 + } } let sampleUser = User( @@ -47,15 +56,6 @@ let sampleProfile = Profile( fullName: "Alice" ) -struct ChatPreview: Identifiable, Decodable { - var chatInfo: ChatInfo - var lastChatItem: ChatItem? - - var id: String { - get { chatInfo.id } - } -} - enum ChatType: String { case direct = "@" case group = "#" @@ -106,7 +106,7 @@ let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact) let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo) -class Chat: Decodable { +class Chat: Decodable, Identifiable { var chatInfo: ChatInfo var chatItems: [ChatItem] @@ -114,6 +114,8 @@ class Chat: Decodable { self.chatInfo = chatInfo self.chatItems = chatItems } + + var id: String { get { chatInfo.id } } } struct Contact: Identifiable, Codable { @@ -172,11 +174,30 @@ struct ChatItem: Identifiable, Decodable { var id: Int64 { get { meta.itemId } } } +func chatItemSample(_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem { + ChatItem( + chatDir: dir, + meta: ciMetaSample(id, ts, text), + content: .sndMsgContent(msgContent: .text(text)) + ) +} + enum CIDirection: Decodable { case directSnd case directRcv case groupSnd case groupRcv(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 { @@ -186,6 +207,15 @@ struct CIMeta: Decodable { var createdAt: Date } +func ciMetaSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta { + CIMeta( + itemId: id, + itemTs: ts, + itemText: text, + createdAt: ts + ) +} + enum CIContent: Decodable { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 944c206c33..a91192a6f3 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -20,6 +20,8 @@ enum ChatCommand { case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) case addContact case connect(connReq: String) + case apiDeleteChat(type: ChatType, id: Int64) + case apiUpdateProfile(profile: Profile) case string(String) var cmdString: String { @@ -35,6 +37,10 @@ enum ChatCommand { return "/c" case let .connect(connReq): return "/c \(connReq)" + case let .apiDeleteChat(type, id): + return "/_del \(type.rawValue)\(id)" + case let .apiUpdateProfile(profile): + return "/p \(profile.displayName) \(profile.fullName)" case let .string(str): return str } @@ -48,11 +54,14 @@ struct APIResponse: Decodable { enum ChatResponse: Decodable, Error { case response(type: String, json: String) - case apiChats(chats: [ChatPreview]) + case apiChats(chats: [Chat]) case apiChat(chat: Chat) case invitation(connReqInvitation: String) case sentConfirmation case sentInvitation + case contactDeleted(contact: Contact) + case userProfileNoChange + case userProfileUpdated(fromProfile: Profile, toProfile: Profile) // case newSentInvitation case contactConnected(contact: Contact) case newChatItem(chatItem: AChatItem) @@ -66,6 +75,9 @@ enum ChatResponse: Decodable, Error { 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 .contactConnected: return "contactConnected" case .newChatItem: return "newChatItem" } @@ -81,6 +93,9 @@ enum ChatResponse: Decodable, Error { case let .invitation(connReqInvitation): return connReqInvitation case .sentConfirmation: return "sentConfirmation: no details" case .sentInvitation: return "sentInvitation: no details" + case let .contactDeleted(contact): return String(describing: contact) + case .userProfileNoChange: return "userProfileNoChange: no details" + case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) case let .contactConnected(contact): return String(describing: contact) case let .newChatItem(chatItem): return String(describing: chatItem) } @@ -156,7 +171,7 @@ func chatRecvMsg() throws -> ChatResponse { chatResponse(chat_recv_msg(getChatCtrl())!) } -func apiGetChats() throws -> [ChatPreview] { +func apiGetChats() throws -> [Chat] { let r = try chatSendCmd(.apiGetChats) if case let .apiChats(chats) = r { return chats } throw r @@ -189,13 +204,28 @@ func apiConnect(connReq: String) throws { } } +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(.apiUpdateProfile(profile: profile)) + switch r { + case .userProfileNoChange: return nil + case let .userProfileUpdated(_, toProfile): return toProfile + default: throw r + } +} + func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { DispatchQueue.main.async { chatModel.terminalItems.append(.resp(Date.now, res)) switch res { case let .contactConnected(contact): chatModel.chatPreviews.insert( - ChatPreview(chatInfo: .direct(contact: contact)), + Chat(chatInfo: .direct(contact: contact), chatItems: []), at: 0 ) case let .newChatItem(aChatItem): @@ -204,7 +234,7 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { chatModel.chats[ci.id] = chat chat.chatItems.append(aChatItem.chatItem) default: - print("unsupported response: ", res) + print("unsupported response: ", res.responseType) } } } @@ -216,7 +246,6 @@ private struct UserResponse: Decodable { private func chatResponse(_ cjson: UnsafePointer) -> ChatResponse { let s = String.init(cString: cjson) - print("chatResponse", s) 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)) @@ -270,7 +299,6 @@ private func getChatCtrl() -> chat_ctrl { private func decodeCJSON(_ cjson: UnsafePointer) -> T? { let s = String.init(cString: cjson) - print("decodeCJSON", s) let d = s.data(using: .utf8)! // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) @@ -286,6 +314,5 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { private func encodeCJSON(_ value: T) -> [CChar] { let data = try! jsonEncoder.encode(value) let str = String(decoding: data, as: UTF8.self) - print("encodeCJSON", str) return str.cString(using: .utf8)! } diff --git a/apps/ios/Shared/Views/ChatListView.swift b/apps/ios/Shared/Views/ChatListView.swift index a29283ee69..8f69dbb9e1 100644 --- a/apps/ios/Shared/Views/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatListView.swift @@ -10,19 +10,13 @@ import SwiftUI struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel + @State private var chatId: String? + @State private var chatsToBeDeleted: IndexSet? + @State private var showDeleteAlert = false + var user: User var body: some View { - DispatchQueue.global().async { - while(true) { - do { - try processReceivedMsg(chatModel, chatRecvMsg()) - } catch { - print("error receiving message: ", error) - } - } - } - return VStack { // if chatModel.chats.isEmpty { // VStack { @@ -31,8 +25,8 @@ struct ChatListView: View { // } // } - ChatHeaderView() - + ChatHeaderView(chatId: $chatId) + NavigationView { List { NavigationLink { @@ -40,35 +34,86 @@ struct ChatListView: View { } label: { Text("Terminal") } - + ForEach(chatModel.chatPreviews) { chatPreview in - NavigationLink { - ChatView(chatInfo: chatPreview.chatInfo) - .onAppear { - do { - let ci = chatPreview.chatInfo - let chat = try apiGetChat(type: ci.chatType, id: ci.apiId) - chatModel.chats[ci.id] = chat - } catch { - print("apiGetChatItems", error) + NavigationLink( + tag: chatPreview.chatInfo.id, + selection: $chatId, + destination: { + ChatView(chatInfo: chatPreview.chatInfo) + .onAppear { + do { + let ci = chatPreview.chatInfo + let chat = try apiGetChat(type: ci.chatType, id: ci.apiId) + chatModel.chats[ci.id] = chat + } catch { + print("apiGetChatItems", error) + } } - } - } label: { - ChatPreviewView(chatPreview: chatPreview) - } + }, label: { + ChatPreviewView(chatPreview: chatPreview) + .alert(isPresented: $showDeleteAlert) { + deleteChatAlert((chatsToBeDeleted?.first)!) + } + } + ) + .frame(height: 80) + } + .onDelete { idx in + chatsToBeDeleted = idx + showDeleteAlert = true } } + .padding(0) + .offset(x: -8) + .listStyle(.plain) + .edgesIgnoringSafeArea(.top) } - .navigationViewStyle(.stack) + } + } + + func deleteChatAlert(_ ix: IndexSet.Element) -> Alert { + let ci = chatModel.chatPreviews[ix].chatInfo + switch ci { + case .direct: + return Alert( + title: Text("Delete contact?"), + message: Text("Contact and all messages will be deleted"), + primaryButton: .destructive(Text("Delete")) { + do { + try apiDeleteChat(type: ci.chatType, id: ci.apiId) + chatModel.chatPreviews.remove(at: ix) + } catch let error { + print("Error: \(error)") + } + chatsToBeDeleted = nil + }, secondaryButton: .cancel() { + chatsToBeDeleted = nil + } + ) + case .group: + return Alert( + title: Text("Delete group"), + message: Text("Group deletion is not supported") + ) } } } -//struct ChatListView_Previews: PreviewProvider { -// static var previews: some View { -// let chatModel = ChatModel() -// chatModel.chatPreviews = [] -// return ChatListView(user: sampleUser) -// .environmentObject(chatModel) -// } -//} +struct ChatListView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chatPreviews = [ + Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + ), + Chat( + chatInfo: sampleGroupChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.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 ChatListView(user: sampleUser) + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift index 16a0cb8711..eb86336f0a 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -9,19 +9,55 @@ import SwiftUI struct ChatPreviewView: View { - var chatPreview: ChatPreview + var chatPreview: Chat var body: some View { - Text(chatPreview.chatInfo.localDisplayName) + let ci = chatPreview.chatItems.last + return VStack(spacing: 4) { + HStack(alignment: .top) { + Text(chatPreview.chatInfo.localDisplayName) + .font(.title3) + .fontWeight(.bold) + .padding(.leading, 8) + .padding(.top, 4) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + if let ci = ci { + Text(getDateFormatter().string(from: ci.meta.itemTs)) + .font(.subheadline) + .padding(.trailing, 8) + .padding(.top, 4) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(.secondary) + } + } + if let ci = ci { + Text(ci.content.text) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + .padding(.top, 1) + } + } } } struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatPreviewView(chatPreview: ChatPreview(chatInfo: sampleDirectChatInfo)) - ChatPreviewView(chatPreview: ChatPreview(chatInfo: sampleGroupChatInfo)) + ChatPreviewView(chatPreview: Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [] + )) + ChatPreviewView(chatPreview: Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + )) + ChatPreviewView(chatPreview: Chat( + chatInfo: sampleGroupChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.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: 300, height: 70)) + .previewLayout(.fixed(width: 360, height: 80)) } } diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/ChatView.swift index 419fef2f79..fc323fce73 100644 --- a/apps/ios/Shared/Views/ChatView.swift +++ b/apps/ios/Shared/Views/ChatView.swift @@ -18,9 +18,9 @@ struct ChatView: View { if let chat: Chat = chatModel.chats[chatInfo.id] { VStack { ScrollView { - LazyVStack { - ForEach(chat.chatItems) { chatItem in - Text(chatItem.content.text) + LazyVStack(spacing: 5) { + ForEach(chat.chatItems) { + ChatItemView(chatItem: $0) } } } @@ -28,11 +28,13 @@ struct ChatView: View { } else { Text("unexpected: chat not found...") } - - Spacer() + + Spacer(minLength: 0) SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } + .edgesIgnoringSafeArea(.all) + .navigationBarHidden(true) } func sendMessage(_ msg: String) { @@ -53,7 +55,15 @@ struct ChatView_Previews: PreviewProvider { chatModel.chats = [ "@1": Chat( chatInfo: sampleDirectChatInfo, - chatItems: [] + chatItems: [ + chatItemSample(1, .directSnd, Date.now, "hello"), + chatItemSample(2, .directRcv, Date.now, "hi"), + chatItemSample(3, .directRcv, Date.now, "hi there"), + chatItemSample(4, .directRcv, Date.now, "hello again"), + chatItemSample(5, .directSnd, Date.now, "hi there!!!"), + chatItemSample(6, .directSnd, Date.now, "how are you?"), + chatItemSample(7, .directSnd, Date.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(chatInfo: sampleDirectChatInfo) diff --git a/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift index f618be8d25..a515a17008 100644 --- a/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift @@ -9,79 +9,47 @@ import SwiftUI struct ChatHeaderView: 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 + @Binding var chatId: String? + @EnvironmentObject var chatModel: ChatModel var body: some View { HStack { - Button("Edit", action: {}) - Spacer() - Text("Your chats") - Spacer() - Button { showAddChat = true } label: { - Image(systemName: "square.and.pencil") + if let cId = chatId { + Button { chatId = nil } label: { Image(systemName: "chevron.backward") } + Spacer() + Text(chatModel.chats[cId]?.chatInfo.localDisplayName ?? "") + .font(.title3) + Spacer() + EmptyView() + } else { + SettingsButton() + Spacer() + Text("Your chats") + .font(.title3) + Spacer() + NewChatButton() } - .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { - Button("Add contact") { addContactAction() } - Button("Scan QR code") { connectContact = true } - Button("Create group") { createGroup = 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() }) } - .padding(.horizontal) - .padding(.top) - } - - func addContactAction() { - do { - connReqInvitation = try apiAddContact() - addContact = true - } catch { - addContactAlert = true - addContactError = error - print(error) - } - } - - 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 ?? "") - ) + .padding([.horizontal, .top]) } } struct ChatHeaderView_Previews: PreviewProvider { static var previews: some View { - ChatHeaderView() + @State var chatId1: String? = "@1" + @State var chatId2: String? + let chatModel = ChatModel() + chatModel.chats = [ + "@1": Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + ) + ] + return Group { + ChatHeaderView(chatId: $chatId1) + ChatHeaderView(chatId: $chatId2) + } + .previewLayout(.fixed(width: 300, height: 70)) + .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Helpers/ChatItemView.swift b/apps/ios/Shared/Views/Helpers/ChatItemView.swift new file mode 100644 index 0000000000..71e6144f71 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatItemView.swift @@ -0,0 +1,63 @@ +// +// ChatItemView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 30/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private var dateFormatter: DateFormatter? + +struct ChatItemView: View { + var chatItem: ChatItem + + var body: some View { + let sent = chatItem.chatDir.sent + + return VStack { + Group { + Text(chatItem.content.text) + .padding(.top, 8) + .padding(.horizontal, 12) + .frame(minWidth: 200, maxWidth: 300, alignment: .leading) + .foregroundColor(sent ? .white : .primary) + Text(getDateFormatter().string(from: chatItem.meta.itemTs)) + .font(.subheadline) + .foregroundColor(sent ? .white : .secondary) + .padding(.bottom, 8) + .padding(.horizontal, 12) + .frame(minWidth: 200, maxWidth: 300, alignment: .trailing) + } + } + .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) + .cornerRadius(10) + .padding(.horizontal) + .frame( + minWidth: 200, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: sent ? .trailing : .leading + ) + } +} + +func getDateFormatter() -> 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: chatItemSample(1, .directSnd, Date.now, "hello")) + ChatItemView(chatItem: chatItemSample(2, .directRcv, Date.now, "hello there too")) + } + .previewLayout(.fixed(width: 300, height: 70)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/AddContactView.swift b/apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/AddContactView.swift rename to apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift diff --git a/apps/ios/Shared/Views/Helpers/ConnectContactView.swift b/apps/ios/Shared/Views/Helpers/NewChat/ConnectContactView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/ConnectContactView.swift rename to apps/ios/Shared/Views/Helpers/NewChat/ConnectContactView.swift diff --git a/apps/ios/Shared/Views/Helpers/CreateGroupView.swift b/apps/ios/Shared/Views/Helpers/NewChat/CreateGroupView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/CreateGroupView.swift rename to apps/ios/Shared/Views/Helpers/NewChat/CreateGroupView.swift diff --git a/apps/ios/Shared/Views/Helpers/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/Helpers/NewChat/NewChatButton.swift new file mode 100644 index 0000000000..db65dd0dcb --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/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: "square.and.pencil") + } + .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 + print(error) + } + } + + 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/Helpers/QRCode.swift b/apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/QRCode.swift rename to apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/NewChat/ShareSheet.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/ShareSheet.swift rename to apps/ios/Shared/Views/Helpers/NewChat/ShareSheet.swift diff --git a/apps/ios/Shared/Views/Helpers/SendMessageView.swift b/apps/ios/Shared/Views/Helpers/SendMessageView.swift index dc9fe1bfb3..21e69ab84d 100644 --- a/apps/ios/Shared/Views/Helpers/SendMessageView.swift +++ b/apps/ios/Shared/Views/Helpers/SendMessageView.swift @@ -16,11 +16,10 @@ struct SendMessageView: View { var body: some View { HStack { TextField("Message...", text: $command) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .frame(minHeight: 30) - .onSubmit(submit) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .onSubmit(submit) if (inProgress) { ProgressView() @@ -31,7 +30,7 @@ struct SendMessageView: View { } } .frame(minHeight: 30) - .padding() + .padding(12) } func submit() { diff --git a/apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift b/apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift new file mode 100644 index 0000000000..3592feb160 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift @@ -0,0 +1,21 @@ +// +// ProfileHeader.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ProfileHeader: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +struct ProfileHeader_Previews: PreviewProvider { + static var previews: some View { + ProfileHeader() + } +} diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift new file mode 100644 index 0000000000..840751aeb9 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift @@ -0,0 +1,28 @@ +// +// SettingsButton.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SettingsButton: View { + @State private var showSettings = false + + var body: some View { + Button { showSettings = true } label: { + Image(systemName: "gearshape") + } + .sheet(isPresented: $showSettings, content: { + SettingsView() + }) + } +} + +struct SettingsButton_Previews: PreviewProvider { + static var previews: some View { + SettingsButton() + } +} diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsProfile.swift b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsProfile.swift new file mode 100644 index 0000000000..fc3eb7d1c2 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsProfile.swift @@ -0,0 +1,86 @@ +// +// SettingsProfile.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SettingsProfile: 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 chat profile") + .font(.title) + .padding(.bottom) + 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(user) } + } + } + .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() + } + + func saveProfile(_ user: User) { + do { + if let newProfile = try apiUpdateProfile(profile: profile) { + user.profile = newProfile + profile = newProfile + } + } catch { + print(error) + } + editProfile = false + } +} + +struct SettingsProfile_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = sampleUser + return SettingsProfile() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift new file mode 100644 index 0000000000..dd93ce90aa --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift @@ -0,0 +1,27 @@ +// +// SettingsView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var chatModel: ChatModel + + var body: some View { + SettingsProfile() + UserAddress() + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = sampleUser + return SettingsView() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift new file mode 100644 index 0000000000..9cbb63fb9d --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift @@ -0,0 +1,21 @@ +// +// UserAddress.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 31/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct UserAddress: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +struct UserAddress_Previews: PreviewProvider { + static var previews: some View { + UserAddress() + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 79d2bbc2f2..a54706da7b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* 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 */; }; + 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; + 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; @@ -27,6 +29,8 @@ 5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; 5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; 5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; + 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; + 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 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 */; }; @@ -54,6 +58,14 @@ 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.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 /* SettingsProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* SettingsProfile.swift */; }; + 5CB924E227A867BA00ACCCDD /* SettingsProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* SettingsProfile.swift */; }; + 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; + 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.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 */; }; @@ -87,6 +99,7 @@ /* Begin PBXFileReference section */ 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; + 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; 5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C1AEB81279F4A6400247F08 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; @@ -97,6 +110,7 @@ 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; sourceTree = ""; }; 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a"; sourceTree = ""; }; + 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; 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 = ""; }; @@ -118,6 +132,10 @@ 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 = ""; }; 5CA05A4E279752D00002BEB4 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.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 /* SettingsProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsProfile.swift; sourceTree = ""; }; + 5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.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 = ""; }; 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHeaderView.swift; sourceTree = ""; }; @@ -191,12 +209,10 @@ children = ( 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5CA05A4E279752D00002BEB4 /* MessageView.swift */, + 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */, - 5CCD403327A5F6DF00368C90 /* AddContactView.swift */, - 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */, - 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, - 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, - 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, + 5CB924DF27A8678B00ACCCDD /* UserSettings */, + 5CB924DD27A8622200ACCCDD /* NewChat */, ); path = Helpers; sourceTree = ""; @@ -297,6 +313,30 @@ 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 /* SettingsProfile.swift */, + ); + path = UserSettings; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -464,7 +504,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, + 5CB924E127A867BA00ACCCDD /* SettingsProfile.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, + 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, @@ -483,6 +527,8 @@ 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, + 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, + 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -490,7 +536,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */, + 5CB924E227A867BA00ACCCDD /* SettingsProfile.swift in Sources */, 5C764E81279C7276000C6508 /* dummy.m in Sources */, + 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, @@ -509,6 +559,8 @@ 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, + 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */, + 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 8e03eefa9bc36f7969525c1a5284eb8a9982be72 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 31 Jan 2022 23:20:52 +0000 Subject: [PATCH 34/82] update API commands syntax --- src/Simplex/Chat.hs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index abf01ceee7..853c706e30 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1318,12 +1318,12 @@ withStore action = chatCommandP :: Parser ChatCommand chatCommandP = - "/get chats" $> APIGetChats - <|> "/get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal) - <|> "/get chatItems count=" *> (APIGetChatItems <$> A.decimal) - <|> "/send msg " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) - <|> "/_del " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) - <|> "/_ac " *> (APIAcceptContact <$> A.decimal) + "/_get chats" $> APIGetChats + <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal) + <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) + <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) + <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) + <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress From 2295f7a92bb3ef57c5cf62b49051eaa6308c553e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 1 Feb 2022 05:31:34 +0000 Subject: [PATCH 35/82] update commands (#247) --- src/Simplex/Chat.hs | 36 ++++++++++++++++++---------------- src/Simplex/Chat/Controller.hs | 1 + 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 853c706e30..5806f20b9c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -142,7 +142,7 @@ processChatCommand user@User {userId, profile} = \case ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) setActive $ ActiveG gName pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - CTContactRequest -> pure $ CRChatError ChatErrorNotImplemented + CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported" APIDeleteChat cType chatId -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId @@ -157,20 +157,21 @@ processChatCommand user@User {userId, profile} = \case pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs CTGroup -> pure $ CRChatCmdError ChatErrorNotImplemented - CTContactRequest -> do - cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- - withStore $ \st -> - getContactRequest st userId chatId - `E.finally` deleteContactRequest st userId chatId - withAgent $ \a -> rejectContact a connId invId - pure $ CRContactRequestRejected cReq - APIAcceptContact contactRequestId -> do - ctReq@UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId} <- withStore $ \st -> - getContactRequest st userId contactRequestId + CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported" + APIAcceptContact connReqId -> do + cReq@UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId} <- withStore $ \st -> + getContactRequest st userId connReqId procCmd $ do connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile withStore $ \st -> createAcceptedContact st userId connId cName profileId - pure $ CRAcceptingContactRequest ctReq + pure $ CRAcceptingContactRequest cReq + APIRejectContact connReqId -> 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 -> pure $ CRWelcome user AddContact -> procCmd $ do @@ -204,11 +205,11 @@ processChatCommand user@User {userId, profile} = \case pure CRUserContactLinkDeleted ShowMyAddress -> CRUserContactLink <$> withStore (`getUserContactLink` userId) AcceptContact cName -> do - contactRequestId <- withStore $ \st -> getContactRequestIdByName st userId cName - processChatCommand user $ APIAcceptContact contactRequestId + connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName + processChatCommand user $ APIAcceptContact connReqId RejectContact cName -> do - contactRequestId <- withStore $ \st -> getContactRequestIdByName st userId cName - processChatCommand user $ APIDeleteChat CTContactRequest contactRequestId + connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName + processChatCommand user $ APIRejectContact connReqId SendMessage cName msg -> do contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg @@ -1324,6 +1325,7 @@ chatCommandP = <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) <|> "/_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 @@ -1360,7 +1362,7 @@ chatCommandP = <|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat <|> ("/version" <|> "/v") $> ShowVersion where - chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> "<@" $> CTContactRequest + chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 849c5f2883..55fc3eeb16 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,6 +84,7 @@ data ChatCommand | APISendMessage ChatType Int64 MsgContent | APIDeleteChat ChatType Int64 | APIAcceptContact Int64 + | APIRejectContact Int64 | ChatHelp HelpSection | Welcome | AddContact From 0b86402ce39508eef26a72d242b96bca740a3677 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 1 Feb 2022 07:16:02 +0000 Subject: [PATCH 36/82] fix constructor name for JSON encoding (#248) --- src/Simplex/Chat/Messages.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 58f8ddf77a..1b31d8c4d2 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -53,7 +53,7 @@ deriving instance Show (ChatInfo c) data JSONChatInfo = JCInfoDirect {contact :: Contact} | JCInfoGroup {groupInfo :: GroupInfo} - | JCIInfoContactRequest {contactRequest :: UserContactRequest} + | JCInfoContactRequest {contactRequest :: UserContactRequest} deriving (Generic) instance ToJSON JSONChatInfo where @@ -68,7 +68,7 @@ jsonChatInfo :: ChatInfo c -> JSONChatInfo jsonChatInfo = \case DirectChat c -> JCInfoDirect c GroupChat g -> JCInfoGroup g - ContactRequest g -> JCIInfoContactRequest g + ContactRequest g -> JCInfoContactRequest g data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, From 228c118714e95ff3d2d91b8b906d1b8aca15ca2c Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Tue, 1 Feb 2022 15:05:27 +0400 Subject: [PATCH 37/82] api for chat pagination (#249) --- src/Simplex/Chat.hs | 12 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Messages.hs | 6 + src/Simplex/Chat/Store.hs | 213 +++++++++++++++++++++++++-------- 4 files changed, 181 insertions(+), 52 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5806f20b9c..ebbe5c450e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -125,9 +125,9 @@ toView event = do processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResponse processChatCommand user@User {userId, profile} = \case APIGetChats -> CRApiChats <$> withStore (`getChatPreviews` user) - APIGetChat cType cId -> case cType of - CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st userId cId) - CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId) + APIGetChat cType cId pagination -> 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 $ CRChatError ChatErrorNotImplemented APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented APISendMessage cType chatId mc -> case cType of @@ -1320,7 +1320,7 @@ withStore action = chatCommandP :: Parser ChatCommand chatCommandP = "/_get chats" $> APIGetChats - <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal) + <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) @@ -1363,6 +1363,10 @@ chatCommandP = <|> ("/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 /= '@' diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 55fc3eeb16..02ffae68bd 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -79,7 +79,7 @@ instance ToJSON HelpSection where data ChatCommand = APIGetChats - | APIGetChat ChatType Int64 + | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent | APIDeleteChat ChatType Int64 diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 1b31d8c4d2..166ceb340d 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -191,6 +191,12 @@ instance ToJSON CIMeta where toEncoding = J.genericToEncoding J.defaultOptions type ChatItemId = Int64 +data ChatPagination + = CPLast Int + | CPAfter ChatItemId Int + | CPBefore ChatItemId Int + deriving (Show) + type ChatItemTs = UTCTime data CIContent (d :: MsgDirection) where diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index f55fdb122f..5ec2f3db31 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1842,7 +1842,10 @@ getChatPreviews st user = where ts :: AChat -> UTCTime ts (AChat _ (Chat _ [])) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo/ContactRequest createdAt - ts (AChat _ (Chat _ (CChatItem _ (ChatItem _ CIMeta {itemTs} _) : _))) = itemTs + ts (AChat _ (Chat _ (ci : _))) = chatItemTs ci + +chatItemTs :: CChatItem d -> UTCTime +chatItemTs (CChatItem _ (ChatItem _ CIMeta {itemTs} _)) = itemTs getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] getDirectChatPreviews_ db User {userId} = do @@ -1954,12 +1957,76 @@ getContactRequestChatPreviews_ db User {userId} = let cReq = toContactRequest cReqRow in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] -getDirectChat :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m (Chat 'CTDirect) -getDirectChat st userId contactId = +getDirectChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTDirect) +getDirectChat st user contactId pagination = liftIOEither . withTransaction st $ \db -> runExceptT $ do - contact <- ExceptT $ getContact_ db userId contactId - chatItems <- liftIO $ getDirectChatItems_ db userId contactId - pure $ Chat (DirectChat contact) chatItems + 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 + chatItems <- liftIO getDirectChatItemsLast_ + pure $ Chat (DirectChat contact) (sortOn chatItemTs chatItems) + where + getDirectChatItemsLast_ :: IO [CChatItem 'CTDirect] + getDirectChatItemsLast_ = do + tz <- getCurrentTimeZone + map (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT chat_item_id, item_ts, item_content, item_text, created_at + FROM chat_items + WHERE user_id = ? AND contact_id = ? + ORDER BY item_ts 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 + chatItems <- liftIO getDirectChatItemsAfter_ + pure $ Chat (DirectChat contact) chatItems + where + getDirectChatItemsAfter_ :: IO [CChatItem 'CTDirect] + getDirectChatItemsAfter_ = do + tz <- getCurrentTimeZone + map (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT chat_item_id, item_ts, item_content, item_text, created_at + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND chat_item_id > ? + ORDER BY item_ts 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 + chatItems <- liftIO getDirectChatItemsBefore_ + pure $ Chat (DirectChat contact) (sortOn chatItemTs chatItems) + where + getDirectChatItemsBefore_ :: IO [CChatItem 'CTDirect] + getDirectChatItemsBefore_ = do + tz <- getCurrentTimeZone + map (toDirectChatItem tz) + <$> DB.query + db + [sql| + SELECT chat_item_id, item_ts, item_content, item_text, created_at + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND chat_item_id < ? + ORDER BY item_ts DESC + LIMIT ? + |] + (userId, contactId, beforeChatItemId, count) getContactIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 getContactIdByName st userId cName = @@ -2001,26 +2068,100 @@ getContact_ db userId contactId = (userId, contactId, ConnReady, ConnSndReady) ) -getDirectChatItems_ :: DB.Connection -> UserId -> Int64 -> IO [CChatItem 'CTDirect] -getDirectChatItems_ db userId contactId = do - tz <- getCurrentTimeZone - map (toDirectChatItem tz) - <$> DB.query - db - [sql| - SELECT chat_item_id, item_ts, item_content, item_text, created_at - FROM chat_items - WHERE user_id = ? AND contact_id = ? - ORDER BY item_ts ASC - |] - (userId, contactId) - -getGroupChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (Chat 'CTGroup) -getGroupChat st user groupId = +getGroupChat :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatPagination -> m (Chat 'CTGroup) +getGroupChat st user groupId pagination = liftIOEither . withTransaction st $ \db -> runExceptT $ do - groupInfo <- ExceptT $ getGroupInfo_ db user groupId - chatItems <- ExceptT $ getGroupChatItems_ db user groupId - pure $ Chat (GroupChat groupInfo) chatItems + 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 + chatItems <- ExceptT getGroupChatItemsLast_ + pure $ Chat (GroupChat groupInfo) (sortOn chatItemTs chatItems) + 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.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 item_ts 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 + chatItems <- ExceptT getGroupChatItemsAfter_ + pure $ Chat (GroupChat groupInfo) chatItems + 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.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 item_ts 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 + chatItems <- ExceptT getGroupChatItemsBefore_ + pure $ Chat (GroupChat groupInfo) (sortOn chatItemTs chatItems) + 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.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 item_ts DESC + LIMIT ? + |] + (userId, groupId, beforeChatItemId, count) getGroupInfo :: StoreMonad m => SQLiteStore -> User -> Int64 -> m GroupInfo getGroupInfo st user groupId = @@ -2048,28 +2189,6 @@ getGroupInfo_ db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getGroupChatItems_ :: DB.Connection -> User -> Int64 -> IO (Either StoreError [CChatItem 'CTGroup]) -getGroupChatItems_ db User {userId, userContactId} groupId = 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.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.item_ts ASC - |] - (userId, groupId) - getGroupIdByName :: StoreMonad m => SQLiteStore -> User -> GroupName -> m Int64 getGroupIdByName st user gName = liftIOEither . withTransaction st $ \db -> getGroupIdByName_ db user gName From a8a7bb3c99d11dfafebc91d29f502431d8b28d47 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Tue, 1 Feb 2022 17:04:44 +0400 Subject: [PATCH 38/82] return accepted contact from APIAcceptContact (#250) --- src/Simplex/Chat.hs | 6 +++--- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Store.hs | 9 ++++----- src/Simplex/Chat/View.hs | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ebbe5c450e..900e5d2e6d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -159,12 +159,12 @@ processChatCommand user@User {userId, profile} = \case CTGroup -> pure $ CRChatCmdError ChatErrorNotImplemented CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported" APIAcceptContact connReqId -> do - cReq@UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId} <- withStore $ \st -> + UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p} <- withStore $ \st -> getContactRequest st userId connReqId procCmd $ do connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile - withStore $ \st -> createAcceptedContact st userId connId cName profileId - pure $ CRAcceptingContactRequest cReq + acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p + pure $ CRAcceptingContactRequest acceptedContact APIRejectContact connReqId -> do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \st -> diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 02ffae68bd..dc718bc059 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -148,7 +148,7 @@ data ChatResponse | CRUserContactLinkCreated {connReqContact :: ConnReqContact} | CRUserContactLinkDeleted | CRReceivedContactRequest {contactRequest :: UserContactRequest} - | CRAcceptingContactRequest {contactRequest :: UserContactRequest} + | CRAcceptingContactRequest {contact :: Contact} | CRLeftMemberUser {groupInfo :: GroupInfo} | CRGroupDeletedUser {groupInfo :: GroupInfo} | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 5ec2f3db31..6429b531f2 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -537,13 +537,14 @@ deleteContactRequest st userId contactRequestId = (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 -> m () -createAcceptedContact st userId agentConnId localDisplayName profileId = +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 "INSERT INTO contacts (user_id, local_display_name, contact_profile_id) VALUES (?,?,?)" (userId, localDisplayName, profileId) contactId <- insertedRowId db - void $ createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing} getLiveSndFileTransfers :: MonadUnliftIO m => SQLiteStore -> User -> m [SndFileTransfer] getLiveSndFileTransfers st User {userId} = @@ -2041,8 +2042,6 @@ getContact :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m Contact getContact st userId contactId = liftIOEither . withTransaction st $ \db -> getContact_ db userId contactId --- TODO return the last connection that is ready, not any last connection --- requires updating connection status getContact_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError Contact) getContact_ db userId contactId = join diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 230253a44c..35fb1b2141 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -60,8 +60,8 @@ responseToView cmd = \case CRInvitation cReq -> r' $ viewConnReqInvitation cReq CRSentConfirmation -> r' ["confirmation sent!"] CRSentInvitation -> r' ["connection request sent!"] - CRContactDeleted Contact {localDisplayName} -> r' [ttyContact localDisplayName <> ": contact is deleted"] - CRAcceptingContactRequest UserContactRequest {localDisplayName = c} -> r' [ttyContact c <> ": accepting contact request..."] + 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..."] From 711207743b51ec3486f40969530f36f5ff598787 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 1 Feb 2022 17:34:06 +0000 Subject: [PATCH 39/82] add support for user addresses (#246) * add support for user addresses * started processing contact requests * update command syntax * fix: make Profile Codable * accept/reject contact requests * update API, accept/reject contact requests --- apps/ios/Shared/Model/ChatModel.swift | 47 +++- apps/ios/Shared/Model/SimpleXAPI.swift | 124 +++++++++-- .../MyPlayground.playground/Contents.swift | 80 +------ .../timeline.xctimeline | 2 +- .../Views/ChatList/ChatListNavLink.swift | 207 ++++++++++++++++++ .../Shared/Views/ChatList/ChatListView.swift | 75 +++++++ .../Views/{ => ChatList}/TerminalView.swift | 1 + apps/ios/Shared/Views/ChatListView.swift | 119 ---------- apps/ios/Shared/Views/ChatPreviewView.swift | 7 + apps/ios/Shared/Views/ChatView.swift | 37 +++- .../Shared/Views/Helpers/ChatHeaderView.swift | 55 ----- .../Shared/Views/Helpers/ChatItemView.swift | 1 + .../Views/Helpers/ChatListNavLink.swift | 21 ++ .../Views/Helpers/ChatListToolbar.swift | 42 ++++ .../Shared/Views/Helpers/MessageView.swift | 34 --- .../Helpers/NewChat/AddContactView.swift | 4 +- .../Shared/Views/Helpers/NewChat/QRCode.swift | 1 + .../Helpers/UserSettings/SettingsButton.swift | 8 + .../Helpers/UserSettings/SettingsView.swift | 2 +- .../Helpers/UserSettings/UserAddress.swift | 58 ++++- ...ettingsProfile.swift => UserProfile.swift} | 8 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 48 ++-- 22 files changed, 635 insertions(+), 346 deletions(-) create mode 100644 apps/ios/Shared/Views/ChatList/ChatListNavLink.swift create mode 100644 apps/ios/Shared/Views/ChatList/ChatListView.swift rename apps/ios/Shared/Views/{ => ChatList}/TerminalView.swift (97%) delete mode 100644 apps/ios/Shared/Views/ChatListView.swift delete mode 100644 apps/ios/Shared/Views/Helpers/ChatHeaderView.swift create mode 100644 apps/ios/Shared/Views/Helpers/ChatListNavLink.swift create mode 100644 apps/ios/Shared/Views/Helpers/ChatListToolbar.swift delete mode 100644 apps/ios/Shared/Views/Helpers/MessageView.swift rename apps/ios/Shared/Views/Helpers/UserSettings/{SettingsProfile.swift => UserProfile.swift} (95%) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 5a866bc617..1d4ef420ed 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,9 +16,10 @@ final class ChatModel: ObservableObject { @Published var chatPreviews: [Chat] = [] @Published var chatItems: [ChatItem] = [] @Published var terminalItems: [TerminalItem] = [] + @Published var userAddress: String? } -class User: Codable { +class User: Decodable { var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName @@ -59,17 +60,20 @@ let sampleProfile = Profile( enum ChatType: String { case direct = "@" case group = "#" + case contactRequest = "<@" } -enum ChatInfo: Identifiable, Codable { +enum ChatInfo: Identifiable, Decodable { 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)" } } } @@ -77,8 +81,9 @@ enum ChatInfo: Identifiable, Codable { var id: String { get { switch self { - case let .direct(contact): return "@\(contact.contactId)" - case let .group(groupInfo): return "#\(groupInfo.groupId)" + case let .direct(contact): return contact.id + case let .group(groupInfo): return groupInfo.id + case let .contactRequest(contactRequest): return contactRequest.id } } } @@ -88,6 +93,7 @@ enum ChatInfo: Identifiable, Codable { switch self { case .direct: return .direct case .group: return .group + case .contactRequest: return .contactRequest } } } @@ -97,6 +103,7 @@ enum ChatInfo: Identifiable, Codable { switch self { case let .direct(contact): return contact.contactId case let .group(groupInfo): return groupInfo.groupId + case let .contactRequest(contactRequest): return contactRequest.contactRequestId } } } @@ -106,6 +113,8 @@ let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact) let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo) +let sampleContactRequestChatInfo = ChatInfo.contactRequest(contactRequest: sampleContactRequest) + class Chat: Decodable, Identifiable { var chatInfo: ChatInfo var chatItems: [ChatItem] @@ -118,22 +127,46 @@ class Chat: Decodable, Identifiable { var id: String { get { chatInfo.id } } } -struct Contact: Identifiable, Codable { +struct Contact: Identifiable, Decodable { var contactId: Int64 var localDisplayName: ContactName var profile: Profile + var activeConn: Connection var viaGroup: Int64? var id: String { get { "@\(contactId)" } } + + var connected: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } } let sampleContact = Contact( contactId: 1, localDisplayName: "alice", + profile: sampleProfile, + activeConn: sampleConnection +) + +struct Connection: Decodable { + var connStatus: String +} + +let sampleConnection = Connection(connStatus: "ready") + +struct UserContactRequest: Decodable { + var contactRequestId: Int64 + var localDisplayName: ContactName + var profile: Profile + + var id: String { get { "<@\(contactRequestId)" } } +} + +let sampleContactRequest = UserContactRequest( + contactRequestId: 1, + localDisplayName: "alice", profile: sampleProfile ) -struct GroupInfo: Identifiable, Codable { +struct GroupInfo: Identifiable, Decodable { var groupId: Int64 var localDisplayName: GroupName var groupProfile: GroupProfile @@ -157,7 +190,7 @@ let sampleGroupProfile = GroupProfile( fullName: "My Team" ) -struct GroupMember: Codable { +struct GroupMember: Decodable { } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a91192a6f3..94aaac5506 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -22,25 +22,40 @@ enum ChatCommand { case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiUpdateProfile(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 .apiGetChats: - return "/get chats" + return "/_get chats" case let .apiGetChat(type, id): - return "/get chat \(type.rawValue)\(id)" + return "/_get chat \(type.rawValue)\(id) count=500" case let .apiSendMessage(type, id, mc): - return "/send msg \(type.rawValue)\(id) \(mc.cmdString)" + return "/_send \(type.rawValue)\(id) \(mc.cmdString)" case .addContact: - return "/c" + return "/connect" case let .connect(connReq): - return "/c \(connReq)" + return "/connect \(connReq)" case let .apiDeleteChat(type, id): - return "/_del \(type.rawValue)\(id)" + return "/_delete \(type.rawValue)\(id)" case let .apiUpdateProfile(profile): - return "/p \(profile.displayName) \(profile.fullName)" + 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 } @@ -62,9 +77,15 @@ enum ChatResponse: Decodable, Error { case contactDeleted(contact: Contact) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) -// case newSentInvitation + 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 newChatItem(chatItem: AChatItem) + case chatCmdError(chatError: ChatError) var responseType: String { get { @@ -78,8 +99,15 @@ enum ChatResponse: Decodable, Error { 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 .newChatItem: return "newChatItem" + case .chatCmdError: return "chatCmdError" } } } @@ -91,16 +119,25 @@ enum ChatResponse: Decodable, Error { 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 "sentConfirmation: no details" - case .sentInvitation: return "sentInvitation: no details" + case .sentConfirmation: return noDetails + case .sentInvitation: return noDetails case let .contactDeleted(contact): return String(describing: contact) - case .userProfileNoChange: return "userProfileNoChange: no details" + 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 .newChatItem(chatItem): return String(describing: chatItem) + case let .chatCmdError(chatError): return String(describing: chatError) } } } + + private var noDetails: String { get { "\(responseType): no details" } } } enum TerminalItem: Identifiable { @@ -219,20 +256,67 @@ func apiUpdateProfile(profile: Profile) throws -> Profile? { } } +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 processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { DispatchQueue.main.async { chatModel.terminalItems.append(.resp(Date.now, res)) switch res { case let .contactConnected(contact): - chatModel.chatPreviews.insert( - Chat(chatInfo: .direct(contact: contact), chatItems: []), - at: 0 - ) + if let chat = chatModel.chats[contact.id] { + chat.chatInfo = ChatInfo.direct(contact: contact) + } else { + let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) + chatModel.chats[contact.id] = chat + chatModel.chatPreviews.insert(chat, at: 0) + } + case let .receivedContactRequest(contactRequest): + let chat = Chat(chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), chatItems: []) + chatModel.chats[contactRequest.id] = chat + chatModel.chatPreviews.insert(chat, at: 0) case let .newChatItem(aChatItem): let ci = aChatItem.chatInfo let chat = chatModel.chats[ci.id] ?? Chat(chatInfo: ci, chatItems: []) chatModel.chats[ci.id] = chat chat.chatItems.append(aChatItem.chatItem) + if let cp = chatModel.chatPreviews.first(where: { $0.id == ci.id } ) { + cp.chatItems = [aChatItem.chatItem] + } else { + chatModel.chatPreviews.insert(Chat(chatInfo: ci, chatItems: [aChatItem.chatItem]), at: 0) + } default: print("unsupported response: ", res.responseType) } @@ -316,3 +400,13 @@ private func encodeCJSON(_ value: T) -> [CChar] { let str = String(decoding: data, as: UTF8.self) return str.cString(using: .utf8)! } + +enum ChatError: Decodable { + case errorStore(storeError: StoreError) + // TODO other error cases +} + +enum StoreError: Decodable { + case userContactLinkNotFound + // TODO other error cases +} diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift index aa4740fee5..16f46301b3 100644 --- a/apps/ios/Shared/MyPlayground.playground/Contents.swift +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -4,79 +4,11 @@ var greeting = "Hello, playground" let jsonEncoder = JSONEncoder() -let ct = Contact( - contactId: 123, - localDisplayName: "ep", - profile: Profile(displayName: "ep", fullName: "") -) - -//let data = try! jsonEncoder.encode(ChatResponse.contactConnected(contact: ct)) - -//print(String(decoding: data, as: UTF8.self)) - -//var str = """ -//{"resp":{"apiChats":{"chats": -//[{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":2,"profile": -//{"displayName":"simplex","fullName":""},"activeConn": -//{"connLevel":0,"entityId":2,"connType":"contact","connId":1 -//,"agentConnId":"QTRteFhTR1dWQnpQZHE3NQ==","createdAt":"2022-01-27T19:43:44.015562Z","connStatus":"ready"},"localDisplayName":"simplex"}}}}, -//{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":3,"profile": -//{"displayName":"ep","fullName":"Evgeny"},"activeConn": -//{"connLevel":0,"entityId":3,"connType":"contact","connId":2 -//,"agentConnId":"cTdFNkprSHhZZmZhdWFQVg==","createdAt":"2022-01-27T19:47:08.891646Z","connStatus":"ready"},"localDisplayName":"ep"}}}}]}}} -//""" - -//var str = """ -//[{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":2,"profile": -//{"displayName":"simplex","fullName":""},"activeConn": -//{"connLevel":0,"entityId":2,"connType":"contact","connId":1 -//,"agentConnId":"QTRteFhTR1dWQnpQZHE3NQ==","createdAt":"2022-01-27T19:43:44.015562Z","connStatus":"ready"},"localDisplayName":"simplex"}}}}, -//{"chatItem":null,"chatInfo":{"direct":{"contact":{"contactId":3,"profile": -//{"displayName":"ep","fullName":"Evgeny"},"activeConn": -//{"connLevel":0,"entityId":3,"connType":"contact","connId":2 -//,"agentConnId":"cTdFNkprSHhZZmZhdWFQVg==","createdAt":"2022-01-27T19:47:08.891646Z","connStatus":"ready"},"localDisplayName":"ep"}}}}] -//""" -// - -//let str = """ -//{"resp":{"apiDirectChat":{"chat":{"chatInfo":{"direct":{"contact":{"contactId":2,"localDisplayName":"ep","profile":{"displayName":"ep","fullName":"Evgeny"},"activeConn":{"connId":1,"agentConnId":"bUk2OXZlN3lfNXFaVWRWMQ==","connLevel":0,"connType":"contact","connStatus":"ready","entityId":2,"createdAt":"2022-01-29T11:21:18.669786Z"}}}},"chatItems":[{"chatDir":{"directSnd":{}},"meta":{"itemId":1,"itemTs":"2022-01-29T11:21:47.947865Z","itemText":"hello","localItemTs":"2022-01-29T11:21:47.947865Z","createdAt":"2022-01-29T11:21:47.947865Z"},"content":{"sndMsgContent":{"msgContent":{"type":"text","text":"hello"}}}},{"chatDir":{"directRcv":{}},"meta":{"itemId":2,"itemTs":"2022-01-29T11:22:08Z","itemText":"hi","localItemTs":"2022-01-29T11:22:08Z","createdAt":"2022-01-29T11:22:08.563959Z"},"content":{"rcvMsgContent":{"msgContent":{"type":"text","text":"hi"}}}}]}}}} -//""" - -let str = "\"2022-01-29T11:21:47Z\"" - -let data = str.data(using: .utf8)! - -let jsonDecoder = JSONDecoder() - -let df1 = DateFormatter() -df1.locale = Locale(identifier: "en_US_POSIX") -df1.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" -df1.timeZone = TimeZone(secondsFromGMT: 0) - -let df2 = DateFormatter() -df2.locale = Locale(identifier: "en_US_POSIX") -df2.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" -df2.timeZone = TimeZone(secondsFromGMT: 0) - -jsonDecoder.dateDecodingStrategy = .iso8601 // .custom { decoder in -// let container = try decoder.singleValueContainer() -// let string = try container.decode(String.self) -// if let date = df1.date(from: string) ?? df2.date(from: string) { -// return date -// } -// throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)") -//} - - -let r: Date = try! jsonDecoder.decode(Date.self, from: data) - -print(r) - -struct Test: Decodable { - var name: String - var id: Int64 = 0 -} - //jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!) -"\(ChatType.direct)" + +var a = [1, 2, 3] + +a.removeAll(where: { $0 == 1} ) + +print(a) diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline index 19563d37f5..a6df7c391e 100644 --- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -3,7 +3,7 @@ version = "3.0"> diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift new file mode 100644 index 0000000000..7707cd04bf --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -0,0 +1,207 @@ +// +// 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 + + @Binding var chatId: String? + @State var chatPreview: Chat + var width: CGFloat + + @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 chatPreview.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( + chatId: $chatId, + chatInfo: chatPreview.chatInfo, + width: width + ) + .onAppear { + do { + let ci = chatPreview.chatInfo + let chat = try apiGetChat(type: ci.chatType, id: ci.apiId) + chatModel.chats[ci.id] = chat + } catch { + print("apiGetChatItems", error) + } + } + } + + private func contactNavLink(_ contact: Contact) -> some View { + NavigationLink( + tag: chatPreview.chatInfo.id, + selection: $chatId, + destination: { chatView() }, + label: { ChatPreviewView(chatPreview: chatPreview) } + ) + .disabled(!contact.connected) + .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: chatPreview.chatInfo.id, + selection: $chatId, + destination: { chatView() }, + label: { ChatPreviewView(chatPreview: chatPreview) } + ) + .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 { + ChatPreviewView(chatPreview: chatPreview) + .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!) + } + .background(Color(uiColor: .systemBackground)) + .frame(width: width, 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.contactId) + chatModel.chats.removeValue(forKey: contact.id) + chatModel.chatPreviews.removeAll(where: { $0.id == contact.id }) + } catch let error { + print("Error: \(error)") + } + 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 + } + ) + } + + private func acceptContactRequest(_ contactRequest: UserContactRequest) { + do { + let contact = try apiAcceptContactRequest(contactReqId: contactRequest.contactRequestId) + chatModel.chats.removeValue(forKey: contactRequest.id) + let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) + chatModel.chats[contact.id] = chat + if let i = chatModel.chatPreviews.firstIndex(where: { $0.id == contactRequest.id }) { + chatModel.chatPreviews[i] = chat + } else { + chatModel.chatPreviews.insert(chat, at: 0) + } + } catch let error { + print("Error: \(error)") + } + } + + private func rejectContactRequest(_ contactRequest: UserContactRequest) { + do { + try apiRejectContactRequest(contactReqId: contactRequest.contactRequestId) + chatModel.chats.removeValue(forKey: contactRequest.id) + chatModel.chatPreviews.removeAll(where: { $0.id == contactRequest.id }) + } catch let error { + print("Error: \(error)") + } + } +} + +struct ChatListNavLink_Previews: PreviewProvider { + static var previews: some View { + @State var chatId: String? = "@1" + return Group { + ChatListNavLink(chatId: $chatId, chatPreview: Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + ), width: 300) + ChatListNavLink(chatId: $chatId, chatPreview: Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + ), width: 300) + ChatListNavLink(chatId: $chatId, chatPreview: Chat( + chatInfo: sampleContactRequestChatInfo, + chatItems: [] + ), width: 300) + } + .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..caeb4468cf --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -0,0 +1,75 @@ +// +// 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 chatId: String? + + var user: User + + var body: some View { + return VStack { +// if chatModel.chats.isEmpty { +// VStack { +// Text("Hello chat") +// Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") +// } +// } + + NavigationView { + GeometryReader { geometry in + List { + NavigationLink { + TerminalView() + } label: { + Text("Terminal") + } + + ForEach(chatModel.chatPreviews) { chatPreview in + ChatListNavLink( + chatId: $chatId, + chatPreview: chatPreview, + width: geometry.size.width + ) + } + } + .padding(0) + .offset(x: -8) + .listStyle(.plain) + .toolbar { ChatListToolbar(width: geometry.size.width) } + .navigationBarTitleDisplayMode(.inline) + } + } + } + } +} + +struct ChatListView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chatPreviews = [ + Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + ), + Chat( + chatInfo: sampleGroupChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.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: sampleContactRequestChatInfo, + chatItems: [] + ) + + ] + return ChatListView(user: sampleUser) + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/ChatList/TerminalView.swift similarity index 97% rename from apps/ios/Shared/Views/TerminalView.swift rename to apps/ios/Shared/Views/ChatList/TerminalView.swift index a58f1ea9ab..55f9bbaeb7 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/ChatList/TerminalView.swift @@ -20,6 +20,7 @@ struct TerminalView: View { NavigationLink { ScrollView { Text(item.details) + .textSelection(.enabled) } } label: { Text(item.label) diff --git a/apps/ios/Shared/Views/ChatListView.swift b/apps/ios/Shared/Views/ChatListView.swift deleted file mode 100644 index 8f69dbb9e1..0000000000 --- a/apps/ios/Shared/Views/ChatListView.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// 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 chatId: String? - @State private var chatsToBeDeleted: IndexSet? - @State private var showDeleteAlert = false - - var user: User - - var body: some View { - return VStack { -// if chatModel.chats.isEmpty { -// VStack { -// Text("Hello chat") -// Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") -// } -// } - - ChatHeaderView(chatId: $chatId) - - NavigationView { - List { - NavigationLink { - TerminalView() - } label: { - Text("Terminal") - } - - ForEach(chatModel.chatPreviews) { chatPreview in - NavigationLink( - tag: chatPreview.chatInfo.id, - selection: $chatId, - destination: { - ChatView(chatInfo: chatPreview.chatInfo) - .onAppear { - do { - let ci = chatPreview.chatInfo - let chat = try apiGetChat(type: ci.chatType, id: ci.apiId) - chatModel.chats[ci.id] = chat - } catch { - print("apiGetChatItems", error) - } - } - }, label: { - ChatPreviewView(chatPreview: chatPreview) - .alert(isPresented: $showDeleteAlert) { - deleteChatAlert((chatsToBeDeleted?.first)!) - } - } - ) - .frame(height: 80) - } - .onDelete { idx in - chatsToBeDeleted = idx - showDeleteAlert = true - } - } - .padding(0) - .offset(x: -8) - .listStyle(.plain) - .edgesIgnoringSafeArea(.top) - } - } - } - - func deleteChatAlert(_ ix: IndexSet.Element) -> Alert { - let ci = chatModel.chatPreviews[ix].chatInfo - switch ci { - case .direct: - return Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted"), - primaryButton: .destructive(Text("Delete")) { - do { - try apiDeleteChat(type: ci.chatType, id: ci.apiId) - chatModel.chatPreviews.remove(at: ix) - } catch let error { - print("Error: \(error)") - } - chatsToBeDeleted = nil - }, secondaryButton: .cancel() { - chatsToBeDeleted = nil - } - ) - case .group: - return Alert( - title: Text("Delete group"), - message: Text("Group deletion is not supported") - ) - } - } -} - -struct ChatListView_Previews: PreviewProvider { - static var previews: some View { - let chatModel = ChatModel() - chatModel.chatPreviews = [ - Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] - ), - Chat( - chatInfo: sampleGroupChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.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 ChatListView(user: sampleUser) - .environmentObject(chatModel) - } -} diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift index eb86336f0a..f9a706623e 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -38,6 +38,13 @@ struct ChatPreviewView: View { .padding(.bottom, 4) .padding(.top, 1) } +// else if case let .direct(contact) = chatPreview.chatInfo, !contact.connected { +// Text("Connecting...") +// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) +// .padding([.leading, .trailing], 8) +// .padding(.bottom, 4) +// .padding(.top, 1) +// } } } } diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/ChatView.swift index fc323fce73..38e6969361 100644 --- a/apps/ios/Shared/Views/ChatView.swift +++ b/apps/ios/Shared/Views/ChatView.swift @@ -10,22 +10,22 @@ import SwiftUI struct ChatView: View { @EnvironmentObject var chatModel: ChatModel - @State var inProgress: Bool = false - + @Binding var chatId: String? var chatInfo: ChatInfo + var width: CGFloat + @State private var inProgress: Bool = false + var body: some View { VStack { if let chat: Chat = chatModel.chats[chatInfo.id] { - VStack { - ScrollView { - LazyVStack(spacing: 5) { - ForEach(chat.chatItems) { - ChatItemView(chatItem: $0) - } + ScrollView { + LazyVStack(spacing: 5) { + ForEach(chat.chatItems) { + ChatItemView(chatItem: $0) } } } - } else { + } else { Text("unexpected: chat not found...") } @@ -33,8 +33,20 @@ struct ChatView: View { SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } - .edgesIgnoringSafeArea(.all) - .navigationBarHidden(true) + .toolbar { + HStack { + Button { chatId = nil } label: { Image(systemName: "chevron.backward") } + Spacer() + Text(chatInfo.localDisplayName) + .font(.title3) + Spacer() + EmptyView() + } + .padding(.horizontal) + .frame(minWidth: width, maxWidth: .infinity, alignment: .center) + } + .navigationBarBackButtonHidden(true) + } func sendMessage(_ msg: String) { @@ -51,6 +63,7 @@ struct ChatView: View { struct ChatView_Previews: PreviewProvider { static var previews: some View { + @State var chatId: String? = "@1" let chatModel = ChatModel() chatModel.chats = [ "@1": Chat( @@ -66,7 +79,7 @@ struct ChatView_Previews: PreviewProvider { ] ) ] - return ChatView(chatInfo: sampleDirectChatInfo) + return ChatView(chatId: $chatId, chatInfo: sampleDirectChatInfo, width: 300) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift b/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift deleted file mode 100644 index a515a17008..0000000000 --- a/apps/ios/Shared/Views/Helpers/ChatHeaderView.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ChatHeaderView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 29/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct ChatHeaderView: View { - @Binding var chatId: String? - @EnvironmentObject var chatModel: ChatModel - - var body: some View { - HStack { - if let cId = chatId { - Button { chatId = nil } label: { Image(systemName: "chevron.backward") } - Spacer() - Text(chatModel.chats[cId]?.chatInfo.localDisplayName ?? "") - .font(.title3) - Spacer() - EmptyView() - } else { - SettingsButton() - Spacer() - Text("Your chats") - .font(.title3) - Spacer() - NewChatButton() - } - } - .padding([.horizontal, .top]) - } -} - -struct ChatHeaderView_Previews: PreviewProvider { - static var previews: some View { - @State var chatId1: String? = "@1" - @State var chatId2: String? - let chatModel = ChatModel() - chatModel.chats = [ - "@1": Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] - ) - ] - return Group { - ChatHeaderView(chatId: $chatId1) - ChatHeaderView(chatId: $chatId2) - } - .previewLayout(.fixed(width: 300, height: 70)) - .environmentObject(chatModel) - } -} diff --git a/apps/ios/Shared/Views/Helpers/ChatItemView.swift b/apps/ios/Shared/Views/Helpers/ChatItemView.swift index 71e6144f71..039e64ae72 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemView.swift @@ -23,6 +23,7 @@ struct ChatItemView: View { .padding(.horizontal, 12) .frame(minWidth: 200, maxWidth: 300, alignment: .leading) .foregroundColor(sent ? .white : .primary) + .textSelection(.enabled) Text(getDateFormatter().string(from: chatItem.meta.itemTs)) .font(.subheadline) .foregroundColor(sent ? .white : .secondary) diff --git a/apps/ios/Shared/Views/Helpers/ChatListNavLink.swift b/apps/ios/Shared/Views/Helpers/ChatListNavLink.swift new file mode 100644 index 0000000000..0034622b08 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatListNavLink.swift @@ -0,0 +1,21 @@ +// +// ChatListNavLink.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 01/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatListNavLink: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +struct ChatListNavLink_Previews: PreviewProvider { + static var previews: some View { + ChatListNavLink() + } +} diff --git a/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift b/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift new file mode 100644 index 0000000000..779792e9fb --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift @@ -0,0 +1,42 @@ +// +// ChatListToolbar.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 29/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatListToolbar: View { + @EnvironmentObject var chatModel: ChatModel + var width: CGFloat + + var body: some View { + HStack { + SettingsButton() + Spacer() + Text("Your chats") + .font(.title3) + Spacer() + NewChatButton() + } + .padding(.horizontal) + .frame(minWidth: width, maxWidth: .infinity, alignment: .center) + } +} + +struct ChatListToolbar_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chats = [ + "@1": Chat( + chatInfo: sampleDirectChatInfo, + chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + ) + ] + return ChatListToolbar(width: 300) + .previewLayout(.fixed(width: 300, height: 70)) + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/Helpers/MessageView.swift b/apps/ios/Shared/Views/Helpers/MessageView.swift deleted file mode 100644 index 76ebbc3341..0000000000 --- a/apps/ios/Shared/Views/Helpers/MessageView.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// MessageView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 18/01/2022. -// - -import SwiftUI - -struct MessageView: View { - var message: String - var sent: Bool - let receivedColor: Color = Color(UIColor(red: 240/255, green: 240/255, blue: 240/255, alpha: 1.0)) - - var body: some View { - Text(message) - .padding(10) - .foregroundColor(sent ? Color.white : Color.black) - .background(sent ? Color.blue : receivedColor) - .cornerRadius(10) - .frame(minWidth: 100, - maxWidth: .infinity, - minHeight: 0, - maxHeight: .infinity, - alignment: .leading) - - } -} - -struct MessageView_Previews: PreviewProvider { - static var previews: some View { - MessageView(message: "> Send message: \"Hello world!\"\nSuccessful", sent: false) - } -} diff --git a/apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift b/apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift index fc23891112..0f0b1521c1 100644 --- a/apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift @@ -30,8 +30,8 @@ struct AddContactView: View { Button { shareInvitation = true } label: { Label("Share", systemImage: "square.and.arrow.up") } - .padding() - .shareSheet(isPresented: $shareInvitation, items: [connReqInvitation]) + .padding() + .shareSheet(isPresented: $shareInvitation, items: [connReqInvitation]) } } } diff --git a/apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift b/apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift index b92dc44fd3..4d9b7835b0 100644 --- a/apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift @@ -20,6 +20,7 @@ struct QRCode: View { .resizable() .interpolation(.none) .aspectRatio(1, contentMode: .fit) + .textSelection(.enabled) } } .onAppear { diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift index 840751aeb9..702ebbdeaf 100644 --- a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift +++ b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift @@ -9,6 +9,7 @@ import SwiftUI struct SettingsButton: View { + @EnvironmentObject var chatModel: ChatModel @State private var showSettings = false var body: some View { @@ -17,6 +18,13 @@ struct SettingsButton: View { } .sheet(isPresented: $showSettings, content: { SettingsView() + .onAppear { + do { + chatModel.userAddress = try apiGetUserAddress() + } catch { + print(error) + } + } }) } } diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift index dd93ce90aa..0d26ad87ca 100644 --- a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift @@ -12,7 +12,7 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel var body: some View { - SettingsProfile() + UserProfile() UserAddress() } } diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift index 9cbb63fb9d..1cd497a4d8 100644 --- a/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift @@ -9,13 +9,67 @@ import SwiftUI struct UserAddress: View { + @EnvironmentObject var chatModel: ChatModel + @State private var shareAddressLink = false + @State private var deleteAddressAlert = false + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + VStack (alignment: .leading) { + Text("Your chat address") + .font(.title) + .padding(.bottom) + Text("Your 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 { + print("Error: \(error)") + } + }, secondaryButton: .cancel() + ) + } + .shareSheet(isPresented: $shareAddressLink, items: [userAdress]) + } + .frame(maxWidth: .infinity) + } else { + Button { + do { + chatModel.userAddress = try apiCreateUserAddress() + } catch let error { + print("Error: \(error)") + } + } label: { Label("Create address", systemImage: "qrcode") } + .frame(maxWidth: .infinity) + } + } + .padding() } } struct UserAddress_Previews: PreviewProvider { static var previews: some View { - UserAddress() + 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/Helpers/UserSettings/SettingsProfile.swift b/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift similarity index 95% rename from apps/ios/Shared/Views/Helpers/UserSettings/SettingsProfile.swift rename to apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift index fc3eb7d1c2..9f9bb574db 100644 --- a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsProfile.swift +++ b/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift @@ -1,5 +1,5 @@ // -// SettingsProfile.swift +// UserProfile.swift // SimpleX // // Created by Evgeny Poberezkin on 31/01/2022. @@ -8,7 +8,7 @@ import SwiftUI -struct SettingsProfile: View { +struct UserProfile: View { @EnvironmentObject var chatModel: ChatModel @State private var profile = Profile(displayName: "", fullName: "") @State private var editProfile: Bool = false @@ -76,11 +76,11 @@ struct SettingsProfile: View { } } -struct SettingsProfile_Previews: PreviewProvider { +struct UserProfile_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.currentUser = sampleUser - return SettingsProfile() + return UserProfile() .environmentObject(chatModel) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a54706da7b..1cb858b560 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -56,22 +56,22 @@ 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 */; }; - 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.swift */; }; - 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4E279752D00002BEB4 /* MessageView.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 /* SettingsProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* SettingsProfile.swift */; }; - 5CB924E227A867BA00ACCCDD /* SettingsProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* SettingsProfile.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 */; }; - 5CCD403127A5F1C600368C90 /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */; }; - 5CCD403227A5F1C600368C90 /* ChatHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */; }; + 5CCD403127A5F1C600368C90 /* ChatListToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatListToolbar.swift */; }; + 5CCD403227A5F1C600368C90 /* ChatListToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatListToolbar.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 */; }; @@ -131,14 +131,14 @@ 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 = ""; }; - 5CA05A4E279752D00002BEB4 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.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 /* SettingsProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsProfile.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 = ""; }; - 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHeaderView.swift; sourceTree = ""; }; + 5CCD403027A5F1C600368C90 /* ChatListToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListToolbar.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 = ""; }; @@ -196,10 +196,9 @@ children = ( 5C5F4AC227A5E9AF00B51EF1 /* Helpers */, 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, - 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, + 5CB9250B27A942F300ACCCDD /* ChatList */, 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, 5C2E260E27A30FDC00F70299 /* ChatView.swift */, - 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); path = Views; sourceTree = ""; @@ -208,9 +207,8 @@ isa = PBXGroup; children = ( 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, - 5CA05A4E279752D00002BEB4 /* MessageView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, - 5CCD403027A5F1C600368C90 /* ChatHeaderView.swift */, + 5CCD403027A5F1C600368C90 /* ChatListToolbar.swift */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5CB924DD27A8622200ACCCDD /* NewChat */, ); @@ -332,11 +330,21 @@ 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, - 5CB924E027A867BA00ACCCDD /* SettingsProfile.swift */, + 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, ); path = UserSettings; sourceTree = ""; }; + 5CB9250B27A942F300ACCCDD /* ChatList */ = { + isa = PBXGroup; + children = ( + 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, + 5C2E261127A30FEA00F70299 /* TerminalView.swift */, + 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, + ); + path = ChatList; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -506,7 +514,7 @@ files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, - 5CB924E127A867BA00ACCCDD /* SettingsProfile.swift in Sources */, + 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, @@ -514,8 +522,8 @@ 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, - 5CCD403127A5F1C600368C90 /* ChatHeaderView.swift in Sources */, - 5CA05A4F279752D00002BEB4 /* MessageView.swift in Sources */, + 5CCD403127A5F1C600368C90 /* ChatListToolbar.swift in Sources */, + 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, @@ -538,7 +546,7 @@ files = ( 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */, 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */, - 5CB924E227A867BA00ACCCDD /* SettingsProfile.swift in Sources */, + 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */, 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, @@ -546,8 +554,8 @@ 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */, - 5CCD403227A5F1C600368C90 /* ChatHeaderView.swift in Sources */, - 5CA05A50279752D00002BEB4 /* MessageView.swift in Sources */, + 5CCD403227A5F1C600368C90 /* ChatListToolbar.swift in Sources */, + 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, From a68b591029b558bb536fd3ff084dff2edf7f3c06 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 1 Feb 2022 20:30:33 +0000 Subject: [PATCH 40/82] connect via link with simplex: protocol (#251) --- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/Model/SimpleXAPI.swift | 5 +++ apps/ios/Shared/SimpleXApp.swift | 5 +++ .../Shared/Views/ChatList/ChatListView.swift | 43 ++++++++++++++++++- apps/ios/SimpleX (iOS).entitlements | 12 ++++++ apps/ios/SimpleX--iOS--Info.plist | 19 ++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 10 ++++- 7 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 apps/ios/SimpleX (iOS).entitlements create mode 100644 apps/ios/SimpleX--iOS--Info.plist diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1d4ef420ed..8293af6a65 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -17,6 +17,8 @@ final class ChatModel: ObservableObject { @Published var chatItems: [ChatItem] = [] @Published var terminalItems: [TerminalItem] = [] @Published var userAddress: String? + @Published var appOpenUrl: URL? + @Published var connectViaUrl = false } class User: Decodable { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 94aaac5506..b898e3de46 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -402,10 +402,15 @@ private func encodeCJSON(_ value: T) -> [CChar] { } enum ChatError: Decodable { + case error(errorType: ChatErrorType) case errorStore(storeError: StoreError) // TODO other error cases } +enum ChatErrorType: Decodable { + case invalidConnReq +} + enum StoreError: Decodable { case userContactLinkNotFound // TODO other error cases diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 0666668196..222ed168da 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -19,6 +19,11 @@ struct SimpleXApp: App { WindowGroup { ContentView() .environmentObject(chatModel) + .onOpenURL { url in + chatModel.appOpenUrl = url + chatModel.connectViaUrl = true + print(url) + } .onAppear() { chatModel.currentUser = chatGetUser() } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index caeb4468cf..33d66f54a3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -11,11 +11,13 @@ import SwiftUI struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @State private var chatId: String? + @State private var connectAlert = false + @State private var connectError: Error? var user: User var body: some View { - return VStack { + VStack { // if chatModel.chats.isEmpty { // VStack { // Text("Hello chat") @@ -45,10 +47,49 @@ struct ChatListView: View { .listStyle(.plain) .toolbar { ChatListToolbar(width: geometry.size.width) } .navigationBarTitleDisplayMode(.inline) + .alert(isPresented: $connectAlert) { connectionErrorAlert() } } } + .alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() } } } + + private func connectViaUrlAlert() -> Alert { + if let url = chatModel.appOpenUrl { + var path = url.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 + print(error) + } + chatModel.appOpenUrl = nil + }, secondaryButton: .cancel() { + chatModel.appOpenUrl = nil + } + ) + } else { + return Alert(title: Text("Error: URL not available")) + } + } 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 { 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..08407cc1cf --- /dev/null +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + chat.simplex.app + CFBundleURLSchemes + + simplex + + + + + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1cb858b560..c604bf60aa 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 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 = ""; }; + 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; sourceTree = ""; }; 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a"; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; @@ -249,6 +250,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, 5CA059D1279559F40002BEB4 /* macOS */, @@ -723,13 +725,15 @@ 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 = 1; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSCameraUsageDescription = "$(PRODUCT_NAME) needs camera access to scan QR codes to connect to other app users"; + 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; @@ -764,13 +768,15 @@ 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 = 1; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSCameraUsageDescription = "$(PRODUCT_NAME) needs camera access to scan QR codes to connect to other app users"; + 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; From 9f6385f763a1bfac8ff221ed89fc02a48aeebedb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 11:31:01 +0000 Subject: [PATCH 41/82] update connection status in entity used in controller notifications (#252) * update connection status in entity used in controller notifications * remove unused code --- src/Simplex/Chat.hs | 15 ++++++++++----- src/Simplex/Chat/Protocol.hs | 20 ++++++-------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 900e5d2e6d..8de74e46bd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -487,11 +487,8 @@ subscribeUserConnections = void . runExceptT $ do forM_ conns $ subscribeConnection a . aConnId processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agent -> m () -processAgentMessage user@User {userId, profile} agentConnId agentMessage = do - acEntity <- withStore $ \st -> getConnectionEntity st user agentConnId - forM_ (agentMsgConnStatus agentMessage) $ \status -> - withStore $ \st -> updateConnectionStatus st (fromConnection acEntity) status - case acEntity of +processAgentMessage 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 -> @@ -503,6 +500,14 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = do UserContactConnection conn uc -> processUserContactRequest agentMessage conn uc where + 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 acEntity {entityConnection = conn} + Nothing -> pure acEntity + isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool isMember memId GroupInfo {membership} members = sameMemberId memId membership || isJust (find (sameMemberId memId) members) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 5107ada98a..a6ac03be05 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -15,10 +15,10 @@ 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.Aeson.KeyMap as JM import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Database.SQLite.Simple.FromField (FromField (..)) @@ -31,21 +31,13 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) data ConnectionEntity - = RcvDirectMsgConnection Connection (Maybe Contact) - | RcvGroupMsgConnection Connection GroupInfo GroupMember - | SndFileConnection Connection SndFileTransfer - | RcvFileConnection Connection RcvFileTransfer - | UserContactConnection Connection UserContact + = 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) -fromConnection :: ConnectionEntity -> Connection -fromConnection = \case - RcvDirectMsgConnection conn _ -> conn - RcvGroupMsgConnection conn _ _ -> conn - SndFileConnection conn _ -> conn - RcvFileConnection conn _ -> conn - UserContactConnection conn _ -> conn - -- chat message is sent as JSON with these properties data AppMessage = AppMessage { event :: Text, From 1d1ba8607ebcf2388e710fca3e5e97c9030c2520 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 11:43:52 +0000 Subject: [PATCH 42/82] send message integrity errors to view as a separate notification (#253) --- src/Simplex/Chat.hs | 9 +++++++++ src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/View.hs | 24 +++++++++++++----------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8de74e46bd..be7c9e50ef 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -840,6 +840,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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 @@ -847,6 +848,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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 @@ -859,6 +861,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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 @@ -869,6 +872,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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 @@ -881,6 +885,11 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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' diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index dc718bc059..d1f3a65da1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -123,6 +123,7 @@ data ChatResponse = CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} | CRNewChatItem {chatItem :: AChatItem} + | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile | CRCmdAccepted {corr :: CorrId} | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 35fb1b2141..6897efaf77 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -37,6 +37,7 @@ responseToView cmd = \case CRApiChats chats -> api [sShow chats] CRApiChat chat -> api [sShow chat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item + CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> r [] CRChatHelp section -> case section of HSMain -> r chatHelpInfo @@ -146,6 +147,18 @@ viewChatItem chat (ChatItem cd meta content) = case (chat, cd) of 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 = [ "", @@ -310,17 +323,6 @@ receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do 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] viewSentMessage :: StyledString -> MsgContent -> CIMeta -> [StyledString] viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent From 7ce305e16fcba06b639b5f58c32f0a2c00a76e5c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 12:51:39 +0000 Subject: [PATCH 43/82] ios: fix message view updates (refactor model to make it shallow) (#254) --- apps/ios/Shared/ContentView.swift | 3 +- apps/ios/Shared/Model/ChatModel.swift | 99 +++++++++++++++---- apps/ios/Shared/Model/SimpleXAPI.swift | 34 +++---- .../Views/ChatList/ChatListNavLink.swift | 56 +++++------ .../Shared/Views/ChatList/ChatListView.swift | 8 +- apps/ios/Shared/Views/ChatPreviewView.swift | 22 ++--- apps/ios/Shared/Views/ChatView.swift | 46 ++++----- .../Views/Helpers/ChatListToolbar.swift | 9 -- .../Helpers/UserSettings/UserProfile.swift | 6 +- 9 files changed, 153 insertions(+), 130 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 45298422ea..37463242e8 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -25,8 +25,7 @@ struct ContentView: View { } do { - let chats = try apiGetChats() - chatModel.chatPreviews = chats + chatModel.chats = try apiGetChats() } catch { print(error) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8293af6a65..21b8f13bcd 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -12,29 +12,76 @@ import SwiftUI final class ChatModel: ObservableObject { @Published var currentUser: User? - @Published var chats: Dictionary = [:] - @Published var chatPreviews: [Chat] = [] + // 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 + + func hasChat(_ id: String) -> Bool { + chats.first(where: { $0.id == id }) != nil + } + + func getChat(_ id: String) -> Chat? { + chats.first(where: { $0.id == id }) + } + + func addChat(_ chat: Chat) { + chats.insert(chat, at: 0) + } + + func updateChatInfo(_ cInfo: ChatInfo) { + if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { + chats[ix].chatInfo = cInfo + } + } + + 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 chatId != cInfo.id { + let chat = chats.remove(at: ix) + chats.insert(chat, at: 0) + } + } + if chatId == cInfo.id { + chatItems.append(cItem) + } + } + + func removeChat(_ id: String) { + chats.removeAll(where: { $0.id == id }) + } } -class User: Decodable { +struct User: Decodable { 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 - } +// 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 +// } } let sampleUser = User( @@ -103,9 +150,9 @@ enum ChatInfo: Identifiable, Decodable { var apiId: Int64 { get { switch self { - case let .direct(contact): return contact.contactId - case let .group(groupInfo): return groupInfo.groupId - case let .contactRequest(contactRequest): return contactRequest.contactRequestId + case let .direct(contact): return contact.apiId + case let .group(groupInfo): return groupInfo.apiId + case let .contactRequest(contactRequest): return contactRequest.apiId } } } @@ -117,11 +164,16 @@ let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo) let sampleContactRequestChatInfo = ChatInfo.contactRequest(contactRequest: sampleContactRequest) -class Chat: Decodable, Identifiable { - var chatInfo: ChatInfo - var chatItems: [ChatItem] +final class Chat: ObservableObject, Identifiable { + @Published var chatInfo: ChatInfo + @Published var chatItems: [ChatItem] - init(chatInfo: ChatInfo, chatItems: [ChatItem]) { + init(_ cData: ChatData) { + self.chatInfo = cData.chatInfo + self.chatItems = cData.chatItems + } + + init(chatInfo: ChatInfo, chatItems: [ChatItem] = []) { self.chatInfo = chatInfo self.chatItems = chatItems } @@ -129,6 +181,13 @@ class Chat: Decodable, Identifiable { var id: String { get { chatInfo.id } } } +struct ChatData: Decodable, Identifiable { + var chatInfo: ChatInfo + var chatItems: [ChatItem] + + var id: String { get { chatInfo.id } } +} + struct Contact: Identifiable, Decodable { var contactId: Int64 var localDisplayName: ContactName @@ -137,7 +196,7 @@ struct Contact: Identifiable, Decodable { var viaGroup: Int64? var id: String { get { "@\(contactId)" } } - + var apiId: Int64 { get { contactId } } var connected: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } } @@ -160,6 +219,8 @@ struct UserContactRequest: Decodable { var profile: Profile var id: String { get { "<@\(contactRequestId)" } } + + var apiId: Int64 { get { contactRequestId } } } let sampleContactRequest = UserContactRequest( @@ -174,6 +235,8 @@ struct GroupInfo: Identifiable, Decodable { var groupProfile: GroupProfile var id: String { get { "#\(groupId)" } } + + var apiId: Int64 { get { groupId } } } let sampleGroupInfo = GroupInfo( diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b898e3de46..240c565fbb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -69,8 +69,8 @@ struct APIResponse: Decodable { enum ChatResponse: Decodable, Error { case response(type: String, json: String) - case apiChats(chats: [Chat]) - case apiChat(chat: Chat) + case apiChats(chats: [ChatData]) + case apiChat(chat: ChatData) case invitation(connReqInvitation: String) case sentConfirmation case sentInvitation @@ -210,13 +210,13 @@ func chatRecvMsg() throws -> ChatResponse { func apiGetChats() throws -> [Chat] { let r = try chatSendCmd(.apiGetChats) - if case let .apiChats(chats) = r { return chats } + 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 } + if case let .apiChat(chat) = r { return Chat.init(chat) } throw r } @@ -296,27 +296,19 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { chatModel.terminalItems.append(.resp(Date.now, res)) switch res { case let .contactConnected(contact): - if let chat = chatModel.chats[contact.id] { - chat.chatInfo = ChatInfo.direct(contact: contact) + let cInfo = ChatInfo.direct(contact: contact) + if chatModel.hasChat(contact.id) { + chatModel.updateChatInfo(cInfo) } else { - let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) - chatModel.chats[contact.id] = chat - chatModel.chatPreviews.insert(chat, at: 0) + chatModel.addChat(Chat(chatInfo: cInfo, chatItems: [])) } case let .receivedContactRequest(contactRequest): - let chat = Chat(chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), chatItems: []) - chatModel.chats[contactRequest.id] = chat - chatModel.chatPreviews.insert(chat, at: 0) + chatModel.addChat(Chat( + chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), + chatItems: [] + )) case let .newChatItem(aChatItem): - let ci = aChatItem.chatInfo - let chat = chatModel.chats[ci.id] ?? Chat(chatInfo: ci, chatItems: []) - chatModel.chats[ci.id] = chat - chat.chatItems.append(aChatItem.chatItem) - if let cp = chatModel.chatPreviews.first(where: { $0.id == ci.id } ) { - cp.chatItems = [aChatItem.chatItem] - } else { - chatModel.chatPreviews.insert(Chat(chatInfo: ci, chatItems: [aChatItem.chatItem]), at: 0) - } + chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem) default: print("unsupported response: ", res.responseType) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 7707cd04bf..d7aa1cd5bd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -10,9 +10,7 @@ import SwiftUI struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel - - @Binding var chatId: String? - @State var chatPreview: Chat + @State var chat: Chat var width: CGFloat @State private var showDeleteContactAlert = false @@ -24,7 +22,7 @@ struct ChatListNavLink: View { @State private var alertContactRequest: UserContactRequest? var body: some View { - switch chatPreview.chatInfo { + switch chat.chatInfo { case let .direct(contact): contactNavLink(contact) case let .group(groupInfo): @@ -36,15 +34,15 @@ struct ChatListNavLink: View { private func chatView() -> some View { ChatView( - chatId: $chatId, - chatInfo: chatPreview.chatInfo, + chatInfo: chat.chatInfo, width: width ) .onAppear { do { - let ci = chatPreview.chatInfo - let chat = try apiGetChat(type: ci.chatType, id: ci.apiId) - chatModel.chats[ci.id] = chat + let cInfo = chat.chatInfo + let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId) + chatModel.updateChatInfo(chat.chatInfo) + chatModel.chatItems = chat.chatItems } catch { print("apiGetChatItems", error) } @@ -53,10 +51,10 @@ struct ChatListNavLink: View { private func contactNavLink(_ contact: Contact) -> some View { NavigationLink( - tag: chatPreview.chatInfo.id, - selection: $chatId, + tag: chat.chatInfo.id, + selection: $chatModel.chatId, destination: { chatView() }, - label: { ChatPreviewView(chatPreview: chatPreview) } + label: { ChatPreviewView(chat: chat) } ) .disabled(!contact.connected) .swipeActions(edge: .trailing, allowsFullSwipe: true) { @@ -75,10 +73,10 @@ struct ChatListNavLink: View { private func groupNavLink(_ groupInfo: GroupInfo) -> some View { NavigationLink( - tag: chatPreview.chatInfo.id, - selection: $chatId, + tag: chat.chatInfo.id, + selection: $chatModel.chatId, destination: { chatView() }, - label: { ChatPreviewView(chatPreview: chatPreview) } + label: { ChatPreviewView(chat: chat) } ) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { @@ -95,7 +93,7 @@ struct ChatListNavLink: View { } private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { - ChatPreviewView(chatPreview: chatPreview) + ChatPreviewView(chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { acceptContactRequest(contactRequest) } label: { Label("Accept", systemImage: "checkmark") } @@ -125,9 +123,8 @@ struct ChatListNavLink: View { message: Text("Contact and all messages will be deleted"), primaryButton: .destructive(Text("Delete")) { do { - try apiDeleteChat(type: .direct, id: contact.contactId) - chatModel.chats.removeValue(forKey: contact.id) - chatModel.chatPreviews.removeAll(where: { $0.id == contact.id }) + try apiDeleteChat(type: .direct, id: contact.apiId) + chatModel.removeChat(contact.id) } catch let error { print("Error: \(error)") } @@ -160,15 +157,9 @@ struct ChatListNavLink: View { private func acceptContactRequest(_ contactRequest: UserContactRequest) { do { - let contact = try apiAcceptContactRequest(contactReqId: contactRequest.contactRequestId) - chatModel.chats.removeValue(forKey: contactRequest.id) + let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId) let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) - chatModel.chats[contact.id] = chat - if let i = chatModel.chatPreviews.firstIndex(where: { $0.id == contactRequest.id }) { - chatModel.chatPreviews[i] = chat - } else { - chatModel.chatPreviews.insert(chat, at: 0) - } + chatModel.replaceChat(contactRequest.id, chat) } catch let error { print("Error: \(error)") } @@ -176,9 +167,8 @@ struct ChatListNavLink: View { private func rejectContactRequest(_ contactRequest: UserContactRequest) { do { - try apiRejectContactRequest(contactReqId: contactRequest.contactRequestId) - chatModel.chats.removeValue(forKey: contactRequest.id) - chatModel.chatPreviews.removeAll(where: { $0.id == contactRequest.id }) + try apiRejectContactRequest(contactReqId: contactRequest.apiId) + chatModel.removeChat(contactRequest.id) } catch let error { print("Error: \(error)") } @@ -189,15 +179,15 @@ struct ChatListNavLink_Previews: PreviewProvider { static var previews: some View { @State var chatId: String? = "@1" return Group { - ChatListNavLink(chatId: $chatId, chatPreview: Chat( + ChatListNavLink(chat: Chat( chatInfo: sampleDirectChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] ), width: 300) - ChatListNavLink(chatId: $chatId, chatPreview: Chat( + ChatListNavLink(chat: Chat( chatInfo: sampleDirectChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] ), width: 300) - ChatListNavLink(chatId: $chatId, chatPreview: Chat( + ChatListNavLink(chat: Chat( chatInfo: sampleContactRequestChatInfo, chatItems: [] ), width: 300) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 33d66f54a3..d02b7c09ae 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -10,7 +10,6 @@ import SwiftUI struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel - @State private var chatId: String? @State private var connectAlert = false @State private var connectError: Error? @@ -34,10 +33,9 @@ struct ChatListView: View { Text("Terminal") } - ForEach(chatModel.chatPreviews) { chatPreview in + ForEach(chatModel.chats) { chat in ChatListNavLink( - chatId: $chatId, - chatPreview: chatPreview, + chat: chat, width: geometry.size.width ) } @@ -95,7 +93,7 @@ struct ChatListView: View { struct ChatListView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() - chatModel.chatPreviews = [ + chatModel.chats = [ Chat( chatInfo: sampleDirectChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatPreviewView.swift index f9a706623e..caf51a394f 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatPreviewView.swift @@ -9,21 +9,21 @@ import SwiftUI struct ChatPreviewView: View { - var chatPreview: Chat - + @ObservedObject var chat: Chat + var body: some View { - let ci = chatPreview.chatItems.last + let cItem = chat.chatItems.last return VStack(spacing: 4) { HStack(alignment: .top) { - Text(chatPreview.chatInfo.localDisplayName) + Text(chat.chatInfo.localDisplayName) .font(.title3) .fontWeight(.bold) .padding(.leading, 8) .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - if let ci = ci { - Text(getDateFormatter().string(from: ci.meta.itemTs)) + if let cItem = cItem { + Text(getDateFormatter().string(from: cItem.meta.itemTs)) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) @@ -31,8 +31,8 @@ struct ChatPreviewView: View { .foregroundColor(.secondary) } } - if let ci = ci { - Text(ci.content.text) + if let cItem = cItem { + Text(cItem.content.text) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding([.leading, .trailing], 8) .padding(.bottom, 4) @@ -52,15 +52,15 @@ struct ChatPreviewView: View { struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatPreviewView(chatPreview: Chat( + ChatPreviewView(chat: Chat( chatInfo: sampleDirectChatInfo, chatItems: [] )) - ChatPreviewView(chatPreview: Chat( + ChatPreviewView(chat: Chat( chatInfo: sampleDirectChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] )) - ChatPreviewView(chatPreview: Chat( + ChatPreviewView(chat: Chat( chatInfo: sampleGroupChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.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.")] )) diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/ChatView.swift index 38e6969361..17e2aa9ae8 100644 --- a/apps/ios/Shared/Views/ChatView.swift +++ b/apps/ios/Shared/Views/ChatView.swift @@ -10,23 +10,18 @@ import SwiftUI struct ChatView: View { @EnvironmentObject var chatModel: ChatModel - @Binding var chatId: String? var chatInfo: ChatInfo var width: CGFloat @State private var inProgress: Bool = false var body: some View { VStack { - if let chat: Chat = chatModel.chats[chatInfo.id] { - ScrollView { - LazyVStack(spacing: 5) { - ForEach(chat.chatItems) { - ChatItemView(chatItem: $0) - } + ScrollView { + LazyVStack(spacing: 5) { + ForEach(chatModel.chatItems) { + ChatItemView(chatItem: $0) } } - } else { - Text("unexpected: chat not found...") } Spacer(minLength: 0) @@ -35,7 +30,9 @@ struct ChatView: View { } .toolbar { HStack { - Button { chatId = nil } label: { Image(systemName: "chevron.backward") } + Button { chatModel.chatId = nil } label: { + Image(systemName: "chevron.backward") + } Spacer() Text(chatInfo.localDisplayName) .font(.title3) @@ -52,9 +49,7 @@ struct ChatView: View { func sendMessage(_ msg: String) { do { let chatItem = try apiSendMessage(type: chatInfo.chatType, id: chatInfo.apiId, msg: .text(msg)) - let chat = chatModel.chats[chatInfo.id] ?? Chat(chatInfo: chatInfo, chatItems: []) - chatModel.chats[chatInfo.id] = chat - chat.chatItems.append(chatItem) + chatModel.addChatItem(chatInfo, chatItem) } catch { print(error) } @@ -63,23 +58,18 @@ struct ChatView: View { struct ChatView_Previews: PreviewProvider { static var previews: some View { - @State var chatId: String? = "@1" let chatModel = ChatModel() - chatModel.chats = [ - "@1": Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [ - chatItemSample(1, .directSnd, Date.now, "hello"), - chatItemSample(2, .directRcv, Date.now, "hi"), - chatItemSample(3, .directRcv, Date.now, "hi there"), - chatItemSample(4, .directRcv, Date.now, "hello again"), - chatItemSample(5, .directSnd, Date.now, "hi there!!!"), - chatItemSample(6, .directSnd, Date.now, "how are you?"), - chatItemSample(7, .directSnd, Date.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.") - ] - ) + chatModel.chatId = "@1" + chatModel.chatItems = [ + chatItemSample(1, .directSnd, Date.now, "hello"), + chatItemSample(2, .directRcv, Date.now, "hi"), + chatItemSample(3, .directRcv, Date.now, "hi there"), + chatItemSample(4, .directRcv, Date.now, "hello again"), + chatItemSample(5, .directSnd, Date.now, "hi there!!!"), + chatItemSample(6, .directSnd, Date.now, "how are you?"), + chatItemSample(7, .directSnd, Date.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(chatId: $chatId, chatInfo: sampleDirectChatInfo, width: 300) + return ChatView(chatInfo: sampleDirectChatInfo, width: 300) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift b/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift index 779792e9fb..a792094189 100644 --- a/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift +++ b/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift @@ -9,7 +9,6 @@ import SwiftUI struct ChatListToolbar: View { - @EnvironmentObject var chatModel: ChatModel var width: CGFloat var body: some View { @@ -28,15 +27,7 @@ struct ChatListToolbar: View { struct ChatListToolbar_Previews: PreviewProvider { static var previews: some View { - let chatModel = ChatModel() - chatModel.chats = [ - "@1": Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] - ) - ] return ChatListToolbar(width: 300) .previewLayout(.fixed(width: 300, height: 70)) - .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift index 9f9bb574db..b58648b13e 100644 --- a/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift @@ -34,7 +34,7 @@ struct UserProfile: View { .padding(.bottom) HStack(spacing: 20) { Button("Cancel") { editProfile = false } - Button("Save (and notify contacts)") { saveProfile(user) } + Button("Save (and notify contacts)") { saveProfile() } } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -63,10 +63,10 @@ struct UserProfile: View { .padding() } - func saveProfile(_ user: User) { + func saveProfile() { do { if let newProfile = try apiUpdateProfile(profile: profile) { - user.profile = newProfile + chatModel.currentUser?.profile = newProfile profile = newProfile } } catch { From 88a33990b7bcb4bed818209182a10d0800e2dc0b Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 20:25:36 +0400 Subject: [PATCH 44/82] sort chats w/t items by time of creation; created_at & updated_at in all tables; merge v1.1 migrations (#255) * merge migrations; timestamps * contact created_at * group, contact request created_at * sort * redundant imports --- simplex-chat.cabal | 3 +- .../M20220122_pending_group_messages.hs | 19 - src/Simplex/Chat/Migrations/M20220122_v1_1.hs | 221 ++++++ .../Chat/Migrations/M20220125_chat_items.hs | 35 - src/Simplex/Chat/Store.hs | 660 +++++++++++------- src/Simplex/Chat/Types.hs | 9 +- 6 files changed, 647 insertions(+), 300 deletions(-) delete mode 100644 src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs create mode 100644 src/Simplex/Chat/Migrations/M20220122_v1_1.hs delete mode 100644 src/Simplex/Chat/Migrations/M20220125_chat_items.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 56fa5acbc4..041ee7391a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -25,8 +25,7 @@ library Simplex.Chat.Markdown Simplex.Chat.Messages Simplex.Chat.Migrations.M20220101_initial - Simplex.Chat.Migrations.M20220122_pending_group_messages - Simplex.Chat.Migrations.M20220125_chat_items + Simplex.Chat.Migrations.M20220122_v1_1 Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs b/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs deleted file mode 100644 index c432b19f15..0000000000 --- a/src/Simplex/Chat/Migrations/M20220122_pending_group_messages.hs +++ /dev/null @@ -1,19 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} - -module Simplex.Chat.Migrations.M20220122_pending_group_messages where - -import Database.SQLite.Simple (Query) -import Database.SQLite.Simple.QQ (sql) - -m20220122_pending_group_messages :: Query -m20220122_pending_group_messages = - [sql| --- 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')) -); -|] 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/M20220125_chat_items.hs b/src/Simplex/Chat/Migrations/M20220125_chat_items.hs deleted file mode 100644 index 38196e94d8..0000000000 --- a/src/Simplex/Chat/Migrations/M20220125_chat_items.hs +++ /dev/null @@ -1,35 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} - -module Simplex.Chat.Migrations.M20220125_chat_items where - -import Database.SQLite.Simple (Query) -import Database.SQLite.Simple.QQ (sql) - -m20220125_chat_items :: Query -m20220125_chat_items = - [sql| -CREATE TABLE chat_items ( -- mutable chat_items presented to user - 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, - UNIQUE (chat_item_id, message_id) -); - -ALTER TABLE files ADD COLUMN chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE; -|] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 6429b531f2..180ca2fe97 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -136,8 +136,7 @@ import Data.Maybe (listToMaybe) import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T -import Data.Time (fromGregorian, secondsToDiffTime) -import Data.Time.Clock (UTCTime (UTCTime), getCurrentTime) +import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB @@ -145,8 +144,7 @@ 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_pending_group_messages -import Simplex.Chat.Migrations.M20220125_chat_items +import Simplex.Chat.Migrations.M20220122_v1_1 import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) @@ -162,8 +160,7 @@ import UnliftIO.STM schemaMigrations :: [(String, Query)] schemaMigrations = [ ("20220101_initial", m20220101_initial), - ("20220122_pending_group_messages", m20220122_pending_group_messages), - ("20220125_chat_items", m20220125_chat_items) + ("20220122_v1_1", m20220122_v1_1) ] -- | The list of migrations in ascending order by date @@ -194,12 +191,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) @@ -230,43 +240,52 @@ 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 -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Int -> IO Connection -createConnection_ db userId connType entityId acId 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 (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, connLevel, viaContact, ConnNew, connType, ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, 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 = AgentConnId acId, connType, entityId, viaContact, connLevel, connStatus = ConnNew, createdAt} + pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, connLevel, connStatus = ConnNew, createdAt = currentTs} where 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 + liftIOEither . withTransaction st $ \db -> do + currentTs <- getCurrentTime + createContact_ db userId connId profile Nothing currentTs -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 -> Contact -> m [GroupName] @@ -307,10 +326,14 @@ updateUserProfile st User {userId, userContactId, localDisplayName, profile = Pr 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 + 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 @@ -321,18 +344,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 @@ -342,30 +372,37 @@ 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) :. ConnectionRow +type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, UTCTime) -toContact' :: ContactRow -> Contact -toContact' ((contactId, localDisplayName, viaGroup, displayName, fullName) :. connRow) = +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} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} -toContactOrError :: (Int64, ContactName, Maybe Int64, ContactName, Text) :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName) :. connRow) = +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} + Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} _ -> Left $ SEContactNotReady localDisplayName -- TODO return the last connection that is ready, not any last connection @@ -385,9 +422,13 @@ getUserContacts st User {userId} = 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 = @@ -475,15 +516,20 @@ createContactRequest st userId userContactId invId Profile {displayName, fullNam join <$> withLocalDisplayName db userId displayName (createContactRequest' db) where createContactRequest' db ldn = 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) 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) + (userContactId, invId, profileId, ldn, userId, currentTs, currentTs) contactRequestId <- insertedRowId db getContactRequest_ db userId contactRequestId @@ -500,7 +546,7 @@ getContactRequest_ db userId contactRequestId = [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 + 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) @@ -509,12 +555,12 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName) = do +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} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt} getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 getContactRequestIdByName st userId cName = @@ -541,10 +587,14 @@ createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> C 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 "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 - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing} + 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} = @@ -675,13 +725,21 @@ createSentProbe :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> UserId -> Co 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) = @@ -698,7 +756,11 @@ matchReceivedProbe st userId _from@Contact {contactId} (Probe probe) = 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) + 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 cId : _ -> eitherToMaybe <$> getContact_ db userId cId @@ -716,7 +778,11 @@ matchReceivedProbeHash st userId _from@Contact {contactId} (ProbeHash probeHash) 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 (cId, probe) : _ -> @@ -745,22 +811,34 @@ matchSentProbe st userId _from@Contact {contactId} (Probe probe) = 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) @@ -799,21 +877,21 @@ getConnectionEntity st User {userId, userContactId} 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" + in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} + toContact' _ _ _ = Left $ SEInternal "referenced contact not found" getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ db groupMemberId c = ExceptT $ do firstRow (toGroupAndMember c) (SEInternal "referenced group member not found") $ @@ -822,9 +900,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, - -- GroupInfo {groupProfile} - gp.display_name, gp.full_name, + 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, @@ -886,8 +962,9 @@ getConnectionEntity 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 GroupInfo @@ -895,14 +972,24 @@ 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 GroupInfo {groupId, localDisplayName = displayName, groupProfile, 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 :: @@ -917,18 +1004,25 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv getInvitationGroupId_ :: DB.Connection -> IO (Maybe Int64) getInvitationGroupId_ db = listToMaybe . map fromOnly - <$> DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1;" (connRequest, userId) + <$> 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 - _ <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown - membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) - pure $ GroupInfo {groupId, localDisplayName, groupProfile, 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 @@ -970,7 +1064,7 @@ getUserGroupDetails st User {userId, userContactId} = <$> DB.query db [sql| - SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, + 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 @@ -987,12 +1081,12 @@ getGroupInfoByName st user gName = gId <- ExceptT $ getGroupIdByName_ db user gName ExceptT $ getGroupInfo_ db user gId -type GroupInfoRow = (Int64, GroupName, GroupName, Text) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, UTCTime) :. GroupMemberRow toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName) :. userMemberRow) = +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, createdAt) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow - in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership} + 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 @@ -1065,34 +1159,38 @@ createContactMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> 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 @@ -1100,7 +1198,11 @@ createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> Memb 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 @@ -1112,9 +1214,9 @@ createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember + createNewMember_ db user gInfo newMember currentTs -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember createNewMember_ db User {userId, userContactId} @@ -1127,7 +1229,8 @@ createNewMember_ localDisplayName, memContactId = memberContactId, memProfileId - } = do + } + createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing DB.execute @@ -1135,9 +1238,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 {..} @@ -1154,69 +1258,78 @@ 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 -> Int64 -> GroupMemberIntroStatus -> m () updateIntroStatus st introId introStatus = - liftIO . withTransaction st $ \db -> + 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 ] @@ -1242,8 +1355,9 @@ createIntroReMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> Group 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 @@ -1255,56 +1369,54 @@ createIntroReMember st user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM memContactId = Just contactId, memProfileId } - member <- createNewMember_ db user gInfo 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 @@ -1319,12 +1431,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, @@ -1335,7 +1447,9 @@ 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 (GroupInfo, GroupMember)) @@ -1347,9 +1461,7 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, - -- GroupInfo {groupProfile} - gp.display_name, gp.full_name, + 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, @@ -1386,12 +1498,12 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = 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 @@ -1407,42 +1519,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} 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 acId let fileStatus = FSNew - DB.execute db "INSERT INTO snd_files (file_id, file_status, connection_id) VALUES (?, ?, ?)" (fileId, fileStatus, connId) + 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 -> 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} = @@ -1457,32 +1585,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} = @@ -1492,17 +1627,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 @@ -1554,22 +1703,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 @@ -1595,15 +1758,16 @@ 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} = @@ -1612,8 +1776,9 @@ deleteRcvFileChunks st RcvFileTransfer {fileId} = updateFileTransferChatItemId :: MonadUnliftIO m => SQLiteStore -> FileTransferId -> ChatItemId -> m () updateFileTransferChatItemId st fileId ciId = - liftIO . withTransaction st $ \db -> - DB.execute db "UPDATE files SET chat_item_id = ? WHERE file_id = ?" (ciId, fileId) + 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 = @@ -1675,82 +1840,88 @@ getSndFileTransfers_ db userId 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 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, cmEventTag, 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, cmEventTag, 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 = @@ -1761,7 +1932,7 @@ getMsgDeliveryId_ db connId agentMsgId = SELECT msg_delivery_id FROM msg_deliveries m WHERE m.connection_id = ? AND m.agent_msg_id = ? - LIMIT 1; + LIMIT 1 |] (connId, agentMsgId) where @@ -1772,14 +1943,14 @@ getMsgDeliveryId_ db connId agentMsgId = createPendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> Maybe Int64 -> m () createPendingGroupMessage st groupMemberId messageId introId_ = liftIO . withTransaction st $ \db -> do - createdAt <- getCurrentTime + currentTs <- getCurrentTime DB.execute db [sql| INSERT INTO pending_group_messages - (group_member_id, message_id, group_member_intro_id, created_at) VALUES (?,?,?,?) + (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) |] - (groupMemberId, messageId, introId_, createdAt) + (groupMemberId, messageId, introId_, currentTs, currentTs) getPendingGroupMessages :: MonadUnliftIO m => SQLiteStore -> Int64 -> m [PendingGroupMessage] getPendingGroupMessages st groupMemberId = @@ -1823,7 +1994,10 @@ createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, case createdByMsgId of Nothing -> pure () Just msgId -> - DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id) VALUES (?,?)" (ciId, 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) @@ -1842,8 +2016,10 @@ getChatPreviews st user = pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats) where ts :: AChat -> UTCTime - ts (AChat _ (Chat _ [])) = UTCTime (fromGregorian 2122 1 29) (secondsToDiffTime 0) -- TODO Contact/GroupInfo/ContactRequest createdAt 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 @@ -1857,7 +2033,7 @@ getDirectChatPreviews_ db User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, + 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, @@ -1886,9 +2062,9 @@ getDirectChatPreviews_ db User {userId} = do |] (userId, ConnReady, ConnSndReady) where - toDirectChatPreview :: TimeZone -> ContactRow :. MaybeChatItemRow -> AChat - toDirectChatPreview tz (contactRow :. ciRow_) = - let contact = toContact' contactRow + toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. MaybeChatItemRow -> AChat + toDirectChatPreview tz (contactRow :. connRow :. ciRow_) = + let contact = toContact $ contactRow :. connRow ci_ = toDirectChatItemList tz ciRow_ in AChat SCTDirect $ Chat (DirectChat contact) ci_ @@ -1901,7 +2077,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, + 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, @@ -1945,7 +2121,7 @@ getContactRequestChatPreviews_ db User {userId} = [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 + 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) @@ -2053,7 +2229,7 @@ getContact_ db userId contactId = [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, + 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 @@ -2175,7 +2351,7 @@ getGroupInfo_ db User {userId, userContactId} groupId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, + 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, @@ -2251,21 +2427,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 diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index fe80668468..96f829f3ce 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -71,7 +71,8 @@ data Contact = Contact localDisplayName :: ContactName, profile :: Profile, activeConn :: Connection, - viaGroup :: Maybe Int64 + viaGroup :: Maybe Int64, + createdAt :: UTCTime } deriving (Eq, Show, Generic, FromJSON) @@ -98,7 +99,8 @@ data UserContactRequest = UserContactRequest agentContactConnId :: AgentConnId, -- connection id of user contact localDisplayName :: ContactName, profileId :: Int64, - profile :: Profile + profile :: Profile, + createdAt :: UTCTime } deriving (Eq, Show, Generic, FromJSON) @@ -118,7 +120,8 @@ data GroupInfo = GroupInfo { groupId :: Int64, localDisplayName :: GroupName, groupProfile :: GroupProfile, - membership :: GroupMember + membership :: GroupMember, + createdAt :: UTCTime } deriving (Eq, Show, Generic, FromJSON) From 38424af48e73468ab29eba0661927fe75018ef9b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 16:46:05 +0000 Subject: [PATCH 45/82] refactor files, auto-scrollback for messages (#256) --- .../AccentColor.colorset/Contents.json | 3 + apps/ios/Shared/Model/ChatModel.swift | 8 +- .../{Helpers => Chat}/ChatItemView.swift | 0 .../Shared/Views/{ => Chat}/ChatView.swift | 33 ++++---- .../{Helpers => Chat}/SendMessageView.swift | 0 .../Views/ChatList/ChatListNavLink.swift | 17 ++-- .../Shared/Views/ChatList/ChatListView.swift | 42 +++++----- .../{ => ChatList}/ChatPreviewView.swift | 2 +- .../Views/ChatList/ContactRequestView.swift | 46 ++++++++++ .../Views/Helpers/ChatListNavLink.swift | 21 ----- .../Views/Helpers/ChatListToolbar.swift | 33 -------- .../Helpers/Settings/ProfileHeader.swift | 21 ----- .../NewChat/AddContactView.swift | 0 .../NewChat/ConnectContactView.swift | 0 .../NewChat/CreateGroupView.swift | 0 .../{Helpers => }/NewChat/NewChatButton.swift | 0 .../Views/{Helpers => }/NewChat/QRCode.swift | 0 .../{Helpers => }/NewChat/ShareSheet.swift | 0 .../Views/{ChatList => }/TerminalView.swift | 0 .../UserSettings/SettingsButton.swift | 0 .../UserSettings/SettingsView.swift | 0 .../UserSettings/UserAddress.swift | 0 .../UserSettings/UserProfile.swift | 0 apps/ios/Shared/Views/UserView.swift | 83 ------------------- apps/ios/SimpleX.xcodeproj/project.pbxproj | 30 +++---- 25 files changed, 118 insertions(+), 221 deletions(-) rename apps/ios/Shared/Views/{Helpers => Chat}/ChatItemView.swift (100%) rename apps/ios/Shared/Views/{ => Chat}/ChatView.swift (73%) rename apps/ios/Shared/Views/{Helpers => Chat}/SendMessageView.swift (100%) rename apps/ios/Shared/Views/{ => ChatList}/ChatPreviewView.swift (99%) create mode 100644 apps/ios/Shared/Views/ChatList/ContactRequestView.swift delete mode 100644 apps/ios/Shared/Views/Helpers/ChatListNavLink.swift delete mode 100644 apps/ios/Shared/Views/Helpers/ChatListToolbar.swift delete mode 100644 apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift rename apps/ios/Shared/Views/{Helpers => }/NewChat/AddContactView.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/NewChat/ConnectContactView.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/NewChat/CreateGroupView.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/NewChat/NewChatButton.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/NewChat/QRCode.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/NewChat/ShareSheet.swift (100%) rename apps/ios/Shared/Views/{ChatList => }/TerminalView.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/UserSettings/SettingsButton.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/UserSettings/SettingsView.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/UserSettings/UserAddress.swift (100%) rename apps/ios/Shared/Views/{Helpers => }/UserSettings/UserProfile.swift (100%) delete mode 100644 apps/ios/Shared/Views/UserView.swift diff --git a/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897008..eebacd7431 100644 --- a/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -7,5 +7,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "localizable" : true } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 21b8f13bcd..60a8a9b925 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -32,7 +32,9 @@ final class ChatModel: ObservableObject { } func addChat(_ chat: Chat) { - chats.insert(chat, at: 0) + withAnimation { + chats.insert(chat, at: 0) + } } func updateChatInfo(_ cInfo: ChatInfo) { @@ -64,7 +66,9 @@ final class ChatModel: ObservableObject { } func removeChat(_ id: String) { - chats.removeAll(where: { $0.id == id }) + withAnimation { + chats.removeAll(where: { $0.id == id }) + } } } diff --git a/apps/ios/Shared/Views/Helpers/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/ChatItemView.swift rename to apps/ios/Shared/Views/Chat/ChatItemView.swift diff --git a/apps/ios/Shared/Views/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift similarity index 73% rename from apps/ios/Shared/Views/ChatView.swift rename to apps/ios/Shared/Views/Chat/ChatView.swift index 17e2aa9ae8..77d0776339 100644 --- a/apps/ios/Shared/Views/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -11,15 +11,18 @@ import SwiftUI struct ChatView: View { @EnvironmentObject var chatModel: ChatModel var chatInfo: ChatInfo - var width: CGFloat @State private var inProgress: Bool = false var body: some View { VStack { - ScrollView { - LazyVStack(spacing: 5) { - ForEach(chatModel.chatItems) { - ChatItemView(chatItem: $0) + ScrollViewReader { proxy in + ScrollView { + VStack { + ForEach(chatModel.chatItems, id: \.id) { + ChatItemView(chatItem: $0) + } + .onAppear { scrollToBottom(proxy) } + .onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) } } } } @@ -28,24 +31,26 @@ struct ChatView: View { SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } + .navigationTitle(chatInfo.localDisplayName) .toolbar { - HStack { + ToolbarItem(placement: .navigationBarLeading) { Button { chatModel.chatId = nil } label: { Image(systemName: "chevron.backward") } - Spacer() - Text(chatInfo.localDisplayName) - .font(.title3) - Spacer() - EmptyView() } - .padding(.horizontal) - .frame(minWidth: width, maxWidth: .infinity, alignment: .center) } .navigationBarBackButtonHidden(true) } + func scrollToBottom(_ proxy: ScrollViewProxy) { + if let id = chatModel.chatItems.last?.id { + withAnimation { + proxy.scrollTo(id, anchor: .bottom) + } + } + } + func sendMessage(_ msg: String) { do { let chatItem = try apiSendMessage(type: chatInfo.chatType, id: chatInfo.apiId, msg: .text(msg)) @@ -69,7 +74,7 @@ struct ChatView_Previews: PreviewProvider { chatItemSample(6, .directSnd, Date.now, "how are you?"), chatItemSample(7, .directSnd, Date.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(chatInfo: sampleDirectChatInfo, width: 300) + return ChatView(chatInfo: sampleDirectChatInfo) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Helpers/SendMessageView.swift b/apps/ios/Shared/Views/Chat/SendMessageView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/SendMessageView.swift rename to apps/ios/Shared/Views/Chat/SendMessageView.swift diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index d7aa1cd5bd..7a4317610b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -11,7 +11,6 @@ import SwiftUI struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @State var chat: Chat - var width: CGFloat @State private var showDeleteContactAlert = false @State private var showDeleteGroupAlert = false @@ -33,10 +32,7 @@ struct ChatListNavLink: View { } private func chatView() -> some View { - ChatView( - chatInfo: chat.chatInfo, - width: width - ) + ChatView(chatInfo: chat.chatInfo) .onAppear { do { let cInfo = chat.chatInfo @@ -93,7 +89,7 @@ struct ChatListNavLink: View { } private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { - ChatPreviewView(chat: chat) + ContactRequestView(contactRequest: contactRequest) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { acceptContactRequest(contactRequest) } label: { Label("Accept", systemImage: "checkmark") } @@ -108,8 +104,7 @@ struct ChatListNavLink: View { .alert(isPresented: $showContactRequestAlert) { contactRequestAlert(alertContactRequest!) } - .background(Color(uiColor: .systemBackground)) - .frame(width: width, height: 80) + .frame(height: 80) .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { Button("Accept contact") { acceptContactRequest(contactRequest) } @@ -182,15 +177,15 @@ struct ChatListNavLink_Previews: PreviewProvider { ChatListNavLink(chat: Chat( chatInfo: sampleDirectChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] - ), width: 300) + )) ChatListNavLink(chat: Chat( chatInfo: sampleDirectChatInfo, chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] - ), width: 300) + )) ChatListNavLink(chat: Chat( chatInfo: sampleContactRequestChatInfo, chatItems: [] - ), width: 300) + )) } .previewLayout(.fixed(width: 360, height: 80)) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d02b7c09ae..c7befab266 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -25,28 +25,30 @@ struct ChatListView: View { // } NavigationView { - GeometryReader { geometry in - List { - NavigationLink { - TerminalView() - } label: { - Text("Terminal") - } - - ForEach(chatModel.chats) { chat in - ChatListNavLink( - chat: chat, - width: geometry.size.width - ) - } + List { + NavigationLink { + TerminalView() + } label: { + Text("Terminal") + } + + ForEach(chatModel.chats) { chat in + ChatListNavLink(chat: chat) } - .padding(0) - .offset(x: -8) - .listStyle(.plain) - .toolbar { ChatListToolbar(width: geometry.size.width) } - .navigationBarTitleDisplayMode(.inline) - .alert(isPresented: $connectAlert) { connectionErrorAlert() } } + .padding(0) + .offset(x: -8) + .listStyle(.plain) + .navigationTitle("Your chats") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + SettingsButton() + } + ToolbarItem(placement: .navigationBarTrailing) { + NewChatButton() + } + } + .alert(isPresented: $connectAlert) { connectionErrorAlert() } } .alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() } } diff --git a/apps/ios/Shared/Views/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift similarity index 99% rename from apps/ios/Shared/Views/ChatPreviewView.swift rename to apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index caf51a394f..7447b8cd83 100644 --- a/apps/ios/Shared/Views/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -1,5 +1,5 @@ // -// ChatPreviewView.swift +// ChatNavLabel.swift // SimpleX // // Created by Evgeny Poberezkin on 28/01/2022. diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift new file mode 100644 index 0000000000..140c398d91 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -0,0 +1,46 @@ +// +// 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 VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text("@\(contactRequest.localDisplayName)") + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.blue) + .padding(.leading, 8) + .padding(.top, 4) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + Text("12:34")// getDateFormatter().string(from: cItem.meta.itemTs)) + .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: sampleContactRequest) + .previewLayout(.fixed(width: 360, height: 80)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ChatListNavLink.swift b/apps/ios/Shared/Views/Helpers/ChatListNavLink.swift deleted file mode 100644 index 0034622b08..0000000000 --- a/apps/ios/Shared/Views/Helpers/ChatListNavLink.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ChatListNavLink.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 01/02/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct ChatListNavLink: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -struct ChatListNavLink_Previews: PreviewProvider { - static var previews: some View { - ChatListNavLink() - } -} diff --git a/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift b/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift deleted file mode 100644 index a792094189..0000000000 --- a/apps/ios/Shared/Views/Helpers/ChatListToolbar.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// ChatListToolbar.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 29/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct ChatListToolbar: View { - var width: CGFloat - - var body: some View { - HStack { - SettingsButton() - Spacer() - Text("Your chats") - .font(.title3) - Spacer() - NewChatButton() - } - .padding(.horizontal) - .frame(minWidth: width, maxWidth: .infinity, alignment: .center) - } -} - -struct ChatListToolbar_Previews: PreviewProvider { - static var previews: some View { - return ChatListToolbar(width: 300) - .previewLayout(.fixed(width: 300, height: 70)) - } -} diff --git a/apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift b/apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift deleted file mode 100644 index 3592feb160..0000000000 --- a/apps/ios/Shared/Views/Helpers/Settings/ProfileHeader.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ProfileHeader.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 31/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct ProfileHeader: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -struct ProfileHeader_Previews: PreviewProvider { - static var previews: some View { - ProfileHeader() - } -} diff --git a/apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/NewChat/AddContactView.swift rename to apps/ios/Shared/Views/NewChat/AddContactView.swift diff --git a/apps/ios/Shared/Views/Helpers/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/NewChat/ConnectContactView.swift rename to apps/ios/Shared/Views/NewChat/ConnectContactView.swift diff --git a/apps/ios/Shared/Views/Helpers/NewChat/CreateGroupView.swift b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/NewChat/CreateGroupView.swift rename to apps/ios/Shared/Views/NewChat/CreateGroupView.swift diff --git a/apps/ios/Shared/Views/Helpers/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/NewChat/NewChatButton.swift rename to apps/ios/Shared/Views/NewChat/NewChatButton.swift diff --git a/apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/NewChat/QRCode.swift rename to apps/ios/Shared/Views/NewChat/QRCode.swift diff --git a/apps/ios/Shared/Views/Helpers/NewChat/ShareSheet.swift b/apps/ios/Shared/Views/NewChat/ShareSheet.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/NewChat/ShareSheet.swift rename to apps/ios/Shared/Views/NewChat/ShareSheet.swift diff --git a/apps/ios/Shared/Views/ChatList/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift similarity index 100% rename from apps/ios/Shared/Views/ChatList/TerminalView.swift rename to apps/ios/Shared/Views/TerminalView.swift diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/UserSettings/SettingsButton.swift rename to apps/ios/Shared/Views/UserSettings/SettingsButton.swift diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/UserSettings/SettingsView.swift rename to apps/ios/Shared/Views/UserSettings/SettingsView.swift diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/UserSettings/UserAddress.swift rename to apps/ios/Shared/Views/UserSettings/UserAddress.swift diff --git a/apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift similarity index 100% rename from apps/ios/Shared/Views/Helpers/UserSettings/UserProfile.swift rename to apps/ios/Shared/Views/UserSettings/UserProfile.swift diff --git a/apps/ios/Shared/Views/UserView.swift b/apps/ios/Shared/Views/UserView.swift deleted file mode 100644 index 61e3f5e7ed..0000000000 --- a/apps/ios/Shared/Views/UserView.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// UserView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 27/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct UserView: View { - @EnvironmentObject var chatModel: ChatModel - var user: User - -// private let controller: chat_ctrl -// -// var chatStore: chat_store - -// @State private var logbuffer = [String]() -// @State private var chatcmd: String = "" -// @State private var chatlog: String = "" -// @FocusState private var focused: Bool - -// func addLine(line: String) { -// print(line) -// logbuffer.append(line) -// if(logbuffer.count > 50) { _ = logbuffer.dropFirst() } -// chatlog = logbuffer.joined(separator: "\n") -// } - - var body: some View { - if chatModel.userChats.isEmpty { - VStack { - Text("Hello chat") - Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))") - } - } else { - ChatList() - } - - - -// DispatchQueue.global().async { -// while(true) { -// let msg = String.init(cString: chat_recv_msg(controller)) -// -// DispatchQueue.main.async { -// addLine(line: msg) -// } -// } -// } - -// return VStack { -// ScrollView { -// VStack(alignment: .leading) { -// HStack { Spacer() } -// Text(chatlog) -// .lineLimit(nil) -// .font(.system(.body, design: .monospaced)) -// } -// .frame(maxWidth: .infinity) -// } -// -// TextField("Chat command", text: $chatcmd) -// .focused($focused) -// .onSubmit { -// print(chatcmd) -// var cCmd = chatcmd.cString(using: .utf8)! -//// print(String.init(cString: chat_send_cmd(controller, &cCmd))) -// } -// .textInputAutocapitalization(.never) -// .disableAutocorrection(true) -// .padding() -// } - } - -} - -//struct UserView_Previews: PreviewProvider { -// static var previews: some View { -// UserView() -// } -//} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c604bf60aa..ebbe134a39 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* 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 */; }; 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; @@ -70,8 +72,6 @@ 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 */; }; - 5CCD403127A5F1C600368C90 /* ChatListToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatListToolbar.swift */; }; - 5CCD403227A5F1C600368C90 /* ChatListToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403027A5F1C600368C90 /* ChatListToolbar.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 */; }; @@ -99,6 +99,7 @@ /* 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 = ""; }; 5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; @@ -139,7 +140,6 @@ 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 = ""; }; - 5CCD403027A5F1C600368C90 /* ChatListToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListToolbar.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 = ""; }; @@ -195,25 +195,24 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( - 5C5F4AC227A5E9AF00B51EF1 /* Helpers */, - 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, + 5C5F4AC227A5E9AF00B51EF1 /* Chat */, 5CB9250B27A942F300ACCCDD /* ChatList */, - 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, - 5C2E260E27A30FDC00F70299 /* ChatView.swift */, + 5CB924DD27A8622200ACCCDD /* NewChat */, + 5CB924DF27A8678B00ACCCDD /* UserSettings */, + 5C2E261127A30FEA00F70299 /* TerminalView.swift */, + 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */, ); path = Views; sourceTree = ""; }; - 5C5F4AC227A5E9AF00B51EF1 /* Helpers */ = { + 5C5F4AC227A5E9AF00B51EF1 /* Chat */ = { isa = PBXGroup; children = ( + 5C2E260E27A30FDC00F70299 /* ChatView.swift */, 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, - 5CCD403027A5F1C600368C90 /* ChatListToolbar.swift */, - 5CB924DF27A8678B00ACCCDD /* UserSettings */, - 5CB924DD27A8622200ACCCDD /* NewChat */, ); - path = Helpers; + path = Chat; sourceTree = ""; }; 5C764E5C279C70B7000C6508 /* Libraries */ = { @@ -341,8 +340,9 @@ isa = PBXGroup; children = ( 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, - 5C2E261127A30FEA00F70299 /* TerminalView.swift */, 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, + 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, + 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, ); path = ChatList; sourceTree = ""; @@ -524,7 +524,7 @@ 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, - 5CCD403127A5F1C600368C90 /* ChatListToolbar.swift in Sources */, + 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */, 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, @@ -556,7 +556,7 @@ 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */, - 5CCD403227A5F1C600368C90 /* ChatListToolbar.swift in Sources */, + 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */, 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, From dafdf66adacb5db9c2423807392dac3633381a73 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 17:01:12 +0000 Subject: [PATCH 46/82] update entity connection status to report it correctly (#257) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Protocol.hs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index be7c9e50ef..9f2a71762b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -505,7 +505,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = Just connStatus -> do let conn = (entityConnection acEntity) {connStatus} withStore $ \st -> updateConnectionStatus st conn connStatus - pure acEntity {entityConnection = conn} + pure $ updateEntityConnStatus acEntity connStatus Nothing -> pure acEntity isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index a6ac03be05..3b49f3ad7d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -38,6 +38,16 @@ data ConnectionEntity | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} deriving (Eq, Show) +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 { event :: Text, From 292c3344600abb389f5b8fba7a35fca374049ae6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 17:47:27 +0000 Subject: [PATCH 47/82] make slow commands asynchronous (#258) --- src/Simplex/Chat.hs | 24 +++++++++++++----------- src/Simplex/Chat/View.hs | 4 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9f2a71762b..8e993f1b75 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -358,17 +358,19 @@ processChatCommand user@User {userId, profile} = \case ShowVersion -> pure CRVersionInfo where procCmd :: m ChatResponse -> m ChatResponse - procCmd a = do - a - -- ! below code would make command responses asynchronous where they can be slow - -- ! in View.hs `r'` should be defined as `id` in this case - -- gVar <- asks idsDrg - -- corrId <- liftIO $ CorrId <$> randomBytes gVar 8 - -- q <- asks outputQ - -- void . forkIO $ atomically . writeTBQueue q =<< - -- (Just corrId,) <$> (a `catchError` (pure . CRChatError)) - -- pure $ CRCmdAccepted corrId - -- a corrId + procCmd action = do + -- below code would make command responses asynchronous where they can be slow + -- in View.hs `r'` should be defined as `id` in this case + 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 action = action connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () connect cReq msg = do connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6897efaf77..285b7295c0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -117,8 +117,8 @@ responseToView cmd = \case where api = (highlight cmd :) r = (plain cmd :) - -- this function should be `id` in case of asynchronous command responses - r' = r + -- this function should be `r` for "synchronous" command responses + r' = id viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] viewChatItem chat (ChatItem cd meta content) = case (chat, cd) of From 4724669bce9ebb91bbd8f2a3813fd099f9706640 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Wed, 2 Feb 2022 23:50:43 +0400 Subject: [PATCH 48/82] prepare v1.1.0 (#259) --- package.yaml | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat.hs | 3 +-- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 4 +++- tests/ChatClient.hs | 8 ++++++++ tests/ChatTests.hs | 8 +++----- 7 files changed, 18 insertions(+), 11 deletions(-) diff --git a/package.yaml b/package.yaml index 8aa1ae3f13..b8eb26a949 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.0.2 +version: 1.1.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 041ee7391a..504b01c192 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.2 +version: 1.1.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8e993f1b75..8b8e867d05 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -365,8 +365,7 @@ processChatCommand user@User {userId, profile} = \case corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 void . forkIO $ withAgentLock a . withLock l $ - atomically . writeTBQueue q - =<< (Just corrId,) <$> (action `catchError` (pure . CRChatError)) + (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError)) pure $ CRCmdAccepted corrId -- use function below to make commands "synchronous" -- procCmd :: m ChatResponse -> m ChatResponse diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d1f3a65da1..f3126037de 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -35,7 +35,7 @@ import System.IO (Handle) import UnliftIO.STM versionNumber :: String -versionNumber = "1.0.2" +versionNumber = "1.1.0" versionStr :: String versionStr = "SimpleX Chat v" <> versionNumber diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 285b7295c0..527ce3bacf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -117,7 +117,8 @@ responseToView cmd = \case where api = (highlight cmd :) r = (plain cmd :) - -- this function should be `r` for "synchronous" command responses + -- this function should be `r` for "synchronous", `id` for "asynchronous" command responses + -- r' = r r' = id viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] @@ -482,6 +483,7 @@ viewChatError = \case SEDuplicateContactLink -> ["you already have chat address, to show: " <> highlight' "/sa"] SEUserContactLinkNotFound -> ["no chat address, to create: " <> highlight' "/ad"] 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"] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 5a214e83a8..a7bcf14d2f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -30,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" @@ -116,10 +117,17 @@ testChatN ps test = 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 5ee996bdbd..71e1be0b59 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -15,7 +15,6 @@ import Simplex.Chat.Controller import Simplex.Chat.Types (Profile (..), User (..)) import Simplex.Chat.Util (unlessM) import System.Directory (doesFileExist) -import System.Timeout (timeout) import Test.Hspec aliceProfile :: Profile @@ -339,6 +338,8 @@ 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" @@ -840,7 +841,7 @@ cc <### ls = do cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line ( Expectation -( TestCC -> Expectation cc1 <#? cc2 = do @@ -857,9 +858,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):" From 4dd95c1639fb64d85c6b3e4ee9ef8a50238b8ff7 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Thu, 3 Feb 2022 10:15:38 +0400 Subject: [PATCH 49/82] create release as prerelease; fix windows build (#261) --- .github/workflows/build.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6590ac2b10..e16f719cb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 @@ -84,14 +85,14 @@ jobs: shell: bash run: | stack build --test - echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)" + 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.unix_build.outputs.LOCAL_INSTALL_ROOT }}/bin/simplex-chat + file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat asset_name: ${{ matrix.asset_name }} tag: ${{ github.ref }} @@ -110,14 +111,16 @@ jobs: shell: cmd run: | stack build - echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)" + 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 + file: ${{ steps.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe asset_name: ${{ matrix.asset_name }} tag: ${{ github.ref }} From 24f3637199b02c670b27f79c9d45981434f172ac Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 3 Feb 2022 07:16:29 +0000 Subject: [PATCH 50/82] add animations (#260) * add animations * improve settings screen * app icons --- .../AppIcon.appiconset/Contents.json | 24 +++++++++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 81005 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 824 bytes .../Icon-App-20x20@2x-1.png | Bin 0 -> 1653 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 1653 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 2242 bytes .../Icon-App-29x29@1x-1.png | Bin 0 -> 1218 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 1218 bytes .../Icon-App-29x29@2x-1.png | Bin 0 -> 2475 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 2475 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 3866 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 1653 bytes .../Icon-App-40x40@2x-1.png | Bin 0 -> 3454 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 3454 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 5329 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 5329 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 9614 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 2959 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 7738 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 8756 bytes apps/ios/Shared/ContentView.swift | 2 +- apps/ios/Shared/Model/ChatModel.swift | 17 +++++--- .../Views/UserSettings/SettingsView.swift | 40 +++++++++++++++++- .../Views/UserSettings/UserAddress.swift | 4 +- .../Views/UserSettings/UserProfile.swift | 4 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 +-- 26 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x-1.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json index c136eaff76..d6bfcc76f4 100644 --- a/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,91 +1,115 @@ { "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" 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 0000000000000000000000000000000000000000..6dbc1d95ad32155efd6cbfb8c39ddc6b017ca8f6 GIT binary patch literal 81005 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

8>t`Xta)e^;CY|gwL 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..280e05c98edcd184c92fc267447160f1d6abdfc3 GIT binary patch literal 824 zcmV-81IPS{P)-L=z(3{=RN25p67R-^NN2_;6H}g)eS2>M@YW{ zfxwYt&t9aDR4DvAAcLLh`T)NzQd|uW<4^{>-Zy}TZ)Vga#X%w7cr6=dhvw$_MDxNT zzh+Pd%kSxfw?u+ry1!2$-#jqH-3|KW1RQB?huZiVZziYldU6UrQVHrP?+WGwTv%L! zP$+;{B;wam9t!A^<8k0cGl$8@n~q@7l()yc)ruujAzxKK|7W4nGR~_ZcbB33ayExD zXBC>Kn6Gbv;B-3XFLK51}Ce*ty*w>8))XS~EHZq(bZE58&e`=|35kgR+Ss%xA1;5!9paq8WB=*d|;m3bp#FHW>~ z;+D57D_j?;h||1 z=XQrsk4jg7LL!In3c};tIoJ{C%V8{bkW($eQ)`BBvL+dvGr%5+d82m{m=_Zd2C6PIh#nZrqGGD)Vl1 z1dfrj2g4>XMVit7?i!*T)|udSse8CT9;$cKZQ%+zs7G1VKT9>=bOI(0000#Khfd)rIt;wq+>EJb3NT2c!6x4! z7LkODzgmPX*NgDosF4Wl*`w=$gqxCAfRi<~I8j{-nq8Y^kc8WCtq3bF>G=K|r}J^} z`M^#gI|STORKjtF*i~E#CBtnTHdsIkxQjaByxIrN*v-!Ea^OO5Pn`ef4$>=M@Ou@B*lgI z(PDqVa5yNHT@N%M_l)r6+hQbT(lj0w*C`-{0ZHR6y`oC?9O~9b#aRSaRo6l7=l~au zlU3xd;lpsQ@EUY(8p$y)CnqEa_}Rpj)YtO~G`zb@`+*g=Y7sIg7Fzcn$WB}aXSJj3 zg)J?5ELpP;y6mG+O%FzspOv$#&NXi`HcBLN?&TD0D^SY@~ z(YFsWL}PMC8*+mPNw|=ld|bVMUpTLkU4Mr?1Z-w-cKA3x{#I^1Aheyzzkq2`>yech z14ot03`Y?)BMj>n&pMWO*od%PDNbW&hm27Hc+t((Hc-LtGUoko z&^Laba6qVM$5|$+yS@AO#@@`MQrt|2TQU<$rGj;{0{i^^c-BlaNehhMYU`l8XT-U* z;a+xklf{_<1>~G?zP&QRn91X(3;9PJ3uYK_#@VXk#oP~iww)zH?k_EuT&pHrkae6a zP>iGwTa}Osxg|I6*63M0bEZMak{IJ4zgG$tpW+Rcq}98C2<_d=94Ov-XX02S zXCFgx#cjSn|E&-Sj)F269XSaPnp=eKc4q}VXXF6Xm~<;;&a}=MkotmFeB0LDi@m)N zqV+JtaiF1lDZHN5;)kTq5IT1?CI$yF+-wO>jQnKo6rRED2T!8)z1LCWV7ymNTeps9 zS&<7ue#6#^@3QXpR6F6z!9M&uWr3y#26{e&UsKk=qo<23PK=D4JpubYT~XX3eb5he zamEKx^8wFpU>j9{5e3mgGWCorbr{znAcc>!28Uk=;$227kzfmT&VXb!jK6EdvN~sL)$&+?6y9nza$Xw4-;1pU zA(sstAZ&LwI{nK+d*V@GhIl%E0{$=Ki3k1*gJ+Sa$bZ8=00000NkvXXu0mjf#H$+< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..12a1031ff0a2cee445fba3d237f820a909a3a710 GIT binary patch literal 1653 zcmV-*28#KKP)#Khfd)rIt;wq+>EJb3NT2c!6x4! z7LkODzgmPX*NgDosF4Wl*`w=$gqxCAfRi<~I8j{-nq8Y^kc8WCtq3bF>G=K|r}J^} z`M^#gI|STORKjtF*i~E#CBtnTHdsIkxQjaByxIrN*v-!Ea^OO5Pn`ef4$>=M@Ou@B*lgI z(PDqVa5yNHT@N%M_l)r6+hQbT(lj0w*C`-{0ZHR6y`oC?9O~9b#aRSaRo6l7=l~au zlU3xd;lpsQ@EUY(8p$y)CnqEa_}Rpj)YtO~G`zb@`+*g=Y7sIg7Fzcn$WB}aXSJj3 zg)J?5ELpP;y6mG+O%FzspOv$#&NXi`HcBLN?&TD0D^SY@~ z(YFsWL}PMC8*+mPNw|=ld|bVMUpTLkU4Mr?1Z-w-cKA3x{#I^1Aheyzzkq2`>yech z14ot03`Y?)BMj>n&pMWO*od%PDNbW&hm27Hc+t((Hc-LtGUoko z&^Laba6qVM$5|$+yS@AO#@@`MQrt|2TQU<$rGj;{0{i^^c-BlaNehhMYU`l8XT-U* z;a+xklf{_<1>~G?zP&QRn91X(3;9PJ3uYK_#@VXk#oP~iww)zH?k_EuT&pHrkae6a zP>iGwTa}Osxg|I6*63M0bEZMak{IJ4zgG$tpW+Rcq}98C2<_d=94Ov-XX02S zXCFgx#cjSn|E&-Sj)F269XSaPnp=eKc4q}VXXF6Xm~<;;&a}=MkotmFeB0LDi@m)N zqV+JtaiF1lDZHN5;)kTq5IT1?CI$yF+-wO>jQnKo6rRED2T!8)z1LCWV7ymNTeps9 zS&<7ue#6#^@3QXpR6F6z!9M&uWr3y#26{e&UsKk=qo<23PK=D4JpubYT~XX3eb5he zamEKx^8wFpU>j9{5e3mgGWCorbr{znAcc>!28Uk=;$227kzfmT&VXb!jK6EdvN~sL)$&+?6y9nza$Xw4-;1pU zA(sstAZ&LwI{nK+d*V@GhIl%E0{$=Ki3k1*gJ+Sa$bZ8=00000NkvXXu0mjf#H$+< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..910e01901d493c5da9ce54496fe52a458eb8b62a GIT binary patch literal 2242 zcmV;z2tD_SP)>`k^||>c|AMdBXxBPy~`lOWsWg%_gDGbCRZpqgL%09A|W1oW=zwKme{o#*qyoAD4LLXYo5ar zy_?X#)H$IPrs9&i-+tr zifGp`cJGWF%ua~&E0*K8TKea)G1yP&hdrQgCYIw7Z2#wZ#Eu__lnHT$-k2MIPgE3s za;g!3ON~XpRr#ZS96g@a+E}l!pImLh`R;BkPD#>zye7E3<}Bu}*n;S&aD24m8AIq^ zM<13vSBj4he}<5ZWZ>bSDWX4ZZo!I^r|_zWip({iTx~`Xjh-GFJ$rby_c)vBT$w)u-yjqaL)| zr*lzo;^aBZUA|dBW$G_}gIPMz61#5*E7K#*n-_SA z8$<^XT6=iAee%G-4Lr4V?+C06qYIXn;0U8_?r1fvOxVBMvBowL&$eBqq3FI6?`~)m z8;)(+%IM>*Z8VIlM_`@p#^8?4Q{2E~#ss45g(pRCp8dMX?SQjieOd5ij&=!8^xn5nz8dLs?gEbr}+LW(^9a3_R)ri zG$+XxD~7HXdJYYFp2!knqXx0-cX+`59{Ggj@DlXq^lU7u{z@16cx$`BwmVd?w1q6m zNXHmoCM`zu&@E6we^q}Oh08YKgKbaYxoz)a--rKD#zu|Aa<~`nPfWm#T;Q=9ZS=y4 zHq57CtcKm6vyZ5YXoIOohRHNL}uKA6ZSdQ*mn%7=E=oo=rqYEoz1ih5X$Uk|B z~sX;uxl9d6A7Z>|=fq9)RChS{>{;umy z8O<}hUNmEibYNwCL3;#q&o1BahHk9PAbJy_*J`4V(r$^d->P8wTg{wWsNCl3!k6C)*v_R^5+?QRPmtb$Z#BZ^=X0S@Pz?cktw0ih@LKSqCXp& zptP_r5LPZdd_}}8wA+w&^z{$*tUC7K_BC$hqD1oyeeTi~Wi+kfoB#4#EUK^9jpgtN zWHet5U07MGcnO%%(Lq6Yo_0)zzrgO^5fi5_(K>0vEWTeu^i2H zn0xx|7gstz#AuqsgHijVspxb#R9{os(Sg=p2jZxXHWF^nyrB(;k$9}My!|$yqP+ui zPgJ5s1IwxE{1$V*ti-_|<{3tN6Tf`b!q_=Furhq-&RjwtxCv-e!CbW+Z)+E~K(wl0 zBP|xiE01lE$@sixsw zP+mc^ZayZ2gqYbhXG{Zgx#$VIH8X>k1stMYypLf2y4K_NI=(rku-xu9v?*XuSiUSH zv7D69u{c7Y0)nZdE5-V1Hri)!6`gwzdceMz^&M6wH8d25?;jc7@O%CE0Z=1eT}w_{=#xho24bX>~a$pr%a-a zxUr!q(LDqGhH7J@F?Aj0`=pCglf6DZ&E(q*l`?uq{Oz7m@a>*a@a>-e0j&Z;U&~s5 QivR!s07*qoM6N<$g6T9~Qvd(} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3fa10ee124f0844cc2c85e7d939dd0b6319a9484 GIT binary patch literal 1218 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ab45daa1ba5dcb1ec0c922e6008b3f2289926a1 GIT binary patch literal 2475 zcmV;c2~_rpP)b_Ee1AgM;9CK^-6l}A7@ZD>ubjbu<_fZ$_B+oURJKwdg|+9m=91u^MN znkJgWWb)9ci8UEJZTeSV79Yg4NJIgRyx$Myv9L>jzq5Pn?%s3n-s@h{j^#VEvyXds z_k8yEJ?DJS+3P!ug8u{g{6t*C2o5Ni7eV1D2@&F}3V9D+Z!w!vdl z?f@?@FFl*Zq#GzTH0jJ&aG~cqd=`B_ESxw&^?N(;2TD3q2B$i@VBh@j!!Kq`*Bw?D zP^x4yLjJkEgFR0uJqO2C!V=`l)8`l@DYN_=7{nPbZRTCN!g>r!l}yBH{H49~=4yKViJ$dz0;za*IM06xXpp84sw&$gtU6ON21o;L+8oa$dZJLvG z8H}hOJ&UmBVJj3~u8|yXHQQ`_Fd`K4Dyw86pO`v@RZ?$Al5W!rRrZ&2=YY}6TWDi~ zjsM44$?II)uEr67BLxr%XIy>QfS2sjYnyC773G&@*b+Gkc6;On6 zaf8aZC<|I$+XV5c`A}2a3~O^s;PAd&_{jsIsv1*SQxEZJ1yJ9Nk?;>^V62Zf^u?Ma zu$`EUR-P_n^K7JwxJZy6)HOglLJr~MY)nu_Z!cIseVSWXAt+UnLy~Uv4M0j(F??8* z1&=%!uK0l!)wQrZwE$`x+it$rd$JS)ELLGr-&BG{ccH?N%XJqS@4OC;Yqew;sKu8W79!gpcFVs&n8zZupr8|T21 zMb0*^yi(69DT4Gydlh7^g^Z*)*im^2imR?j&goz_##PTkXB%_7W(XijAx0zS=?Fzg zAt;r9Q_4@lVDN?Xy!~Ktfu+hmky#8M6=%WHAB3^U4e0tY+T;cBeW|i$Y5ZV zR0Ams${U%Al_imlT`NmAUY%0{fdT%qb|uKH_3@HH!Vy+fS+X(3#a@d=(XMJnYA(fj zl*1Z*eIPymfY8Qta}`1ELA!2osHEf^J1k8PFIP&lao5n0@O>dh1DlQ_R92G9Z8Bmu zPF!NMF;>>q#-j7{GuJ3WauMt^b0{tjv&yZo zji8@o)>uDwF9$koKkrth8uVgK9XyJos~jY$A4klTti5cWrKv}aXbO3+wjQ=%!V&w# zrZ%>CKgXsMErSr3D$q+;8*rX1fV$>(x99fu55TJIy>R5!ZRh}#;<*I*QGEl(xC_G3 z)m64_nKIRLr=#40QWPY}ZyQ>L$3=FdkvKWC2#)=FJH$rL6ZThvJXGHZo6eUD!vKxO zX2HPMVm7mE{6$P8EEpf?uK73Rc9}-G_ex!}7dXFZlqhE{*NE(Fcj? zyVz=K z!>QN@6n79MpurOZA<8rxL7OCB_)@y9k|L`UEah67OSkWN&&kH#*nqp2|Byk-+4UG! zGDc?MP2XKGZ!QCIwecO8dk=&!aJx$>ev8)0wfZ()x=`TgH)5O^cVZBHdACLTIQ z(T|jieU>UquZ6C97|KIu%2rYd(h=6x#&hDe=c}A#|230eTKlo`K@+^QLB5yB@{HE}AF}1CFqw${MiZdjIq75hzXG`@>8O_*lb#vN7LW zR{GP3!_ptXbX^gLr9E3(!QAR5n4CQBqy9cV@D7G*62aQ+8m{U;PlBb3rYO=;WhWSX zM+B0j%cx8LSRcU3`s4jmGp55LTrjH@!EVLMl2yYoC5;3mNw;~FDto|QS*>F0*;(4R pNwsM7dfnSiqoB8&MnP{k{Tod^VMdg1$3_4E002ovPDHLkV1hfMup|Hg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b0191a721f064694591c5d1f5b299dc2907d1c9b GIT binary patch literal 3866 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4eeb166acbf306f44a0a9550b9908717409e3d9 GIT binary patch literal 3454 zcmV-^4T18BP);&+05f2kLb|4S2SrCkP}^s#XUm>ldqNX|dTLV>FRA5a%M=yR%DZmn1r_kN znU+_mC$(LzKiX%_`-QwFYhDr%H7_VI12XOJ_cAXIp26e#_U5lJG3eJkmaqk!;^DcQn$w% z!j9fOy$X-y@kkqi5NTm#i9aY<;*JtbEa=ac| zdheItbqKz2O%iT2%&HOxfY^~CFWl%g^(WuARFsMqmFqZ z!CQ*(GHIei*HTl%1`v%kjKUNFlI5*OcoXNWg9mt`8@I|JW#S^(nVk;ZqFZaZeyBir z!>4D#m){*?zgL%A052x*R`8>AbG+<$Xh-I%u(tf61))^E9>BKRo|}!SpQE5 z9g3D1DeulyBU02-Yl${HytFevJ*&@ZR|-7p&C}9{Ky_ z;v3L^%v{*9Y%;`m?I3ir3Jbbz=1R!hd%)%28N>~HYTdbIxU4&qB?jQXRo%M?L1b=w z@iJuCr`WDUGs=JhsMn7xNfKb;;f@wr|kk<8btDne6KbB8zWMF_FU1YK(d5%<|;VZN~0qI~MoNLe$lm zvPpzoa^lbggjfZ$R&A4oNba08o)SwMLX2dO@t7w9k1UVig*d$Xw3s~XI;eM6YWucO zV!`X>mS|(}uEJy&3By?gk2)hQLkYz)rU6#zX`=>#5f_%ZY_qI8Un;`HGI|asmMPG= zLrWNyxe9jd5p>(>WOzjIdzsN4=*0wmMl+ecOz6t6U29i z4S|L=drK9$V!LFi$0ZwSc-Mx-kZupf?CUyhsUJ`;xUDoNs4y^ zJd&!$l48u5*ZV=G)e0F)zmScIN*Xrk`MQK5DuT!piwGnsb+so9z3@n?J9C2g?wbRk z(ozj`vbVEftyYw6myzYEC>OYB(fMdODVSIeYnUf?wI>XBO>{JkHUcpwP8*#BCSwrH zV2MTAot?$trH@c$dCnq;c?f}qvT7j4+3UO(Zk#1*UIitV_lCWU78wXr7jG1LP2vlE zbi?D~^TcQO9c0~ul4uM@`)1q;kEClQl~^AhJ##_$`3DNl!|B2+&=cD}y`s>u1v9SF z&IhjB*w4>8e*u=8sZP{BKy{X8fUHQ)SSeKJanTef2|JeEIq4A7p_SHCvkqhJm8)!T zpi-UZsL!qz1LeJySSN zW7q-6B8WOh`CO_2lG~1&v5JlNwd!Kgb$AIo7Mdr6{C|7K=Db9q2uq)ejVxfew4r89k_O{Lq!&s5r(qv?v z25s=9Eyi-%K#2u^t}ts$EC!mc7-XieG|C$LVU7?yvb^OPBkM*|R40<#&&$n|ZTpH! zZ`F>%vZg>x`-c#1c6jfdQBD{HFV7B-M5C!Md&jY%tdb>eM2JLSnFdQVR8>6ygUK~O z3yXO&`!4Ero=)?1=^4Sxv%VyA>SZ zaS=qCCngqcpJk2_-mM@BaL>L1*!qpK+YXU|#}$Z$*SI%cV)1PH zC!1t@=iB$nA>-f)_`X>~MR+{~w|X?ULrhY*JwOSiX;6@=?JJT1`#%>CQ${Aj{MGp& z^`Z7R`acU<8S?PB2;zhx31F$JhMCK^xI8BH$iE++AOw#m4AH3yLF9?0Kq9e>ZW9R~ zb&6uUWJP5FiRaS9C1Y#~bLAx)c0N+m4SBgX<{3l8M^g08zK`xqiDf}>`SV<6fKh=wE zCQ=jgoF&}KQB3Urd@s!>au$Vy&x(p+wE#R4USoP<%z&+fdRhMY6ZE89^DK1Ux4VH?@XYCfiZ8g0K86N zVeoaIIM|2@X7>3DiV!I#?tUsBqI?t+z4Dz21aHErvx+exD)pJtt zxCr8V2+@i$adzPa*_ha`Sp+l-mL8C}TOI*O1B%S34p323!@j1()qI_vu|qUF$<(c%z4^XH63)py5i{OhX%m4rY07*qoM6N<$g20Au4FCWD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4eeb166acbf306f44a0a9550b9908717409e3d9 GIT binary patch literal 3454 zcmV-^4T18BP);&+05f2kLb|4S2SrCkP}^s#XUm>ldqNX|dTLV>FRA5a%M=yR%DZmn1r_kN znU+_mC$(LzKiX%_`-QwFYhDr%H7_VI12XOJ_cAXIp26e#_U5lJG3eJkmaqk!;^DcQn$w% z!j9fOy$X-y@kkqi5NTm#i9aY<;*JtbEa=ac| zdheItbqKz2O%iT2%&HOxfY^~CFWl%g^(WuARFsMqmFqZ z!CQ*(GHIei*HTl%1`v%kjKUNFlI5*OcoXNWg9mt`8@I|JW#S^(nVk;ZqFZaZeyBir z!>4D#m){*?zgL%A052x*R`8>AbG+<$Xh-I%u(tf61))^E9>BKRo|}!SpQE5 z9g3D1DeulyBU02-Yl${HytFevJ*&@ZR|-7p&C}9{Ky_ z;v3L^%v{*9Y%;`m?I3ir3Jbbz=1R!hd%)%28N>~HYTdbIxU4&qB?jQXRo%M?L1b=w z@iJuCr`WDUGs=JhsMn7xNfKb;;f@wr|kk<8btDne6KbB8zWMF_FU1YK(d5%<|;VZN~0qI~MoNLe$lm zvPpzoa^lbggjfZ$R&A4oNba08o)SwMLX2dO@t7w9k1UVig*d$Xw3s~XI;eM6YWucO zV!`X>mS|(}uEJy&3By?gk2)hQLkYz)rU6#zX`=>#5f_%ZY_qI8Un;`HGI|asmMPG= zLrWNyxe9jd5p>(>WOzjIdzsN4=*0wmMl+ecOz6t6U29i z4S|L=drK9$V!LFi$0ZwSc-Mx-kZupf?CUyhsUJ`;xUDoNs4y^ zJd&!$l48u5*ZV=G)e0F)zmScIN*Xrk`MQK5DuT!piwGnsb+so9z3@n?J9C2g?wbRk z(ozj`vbVEftyYw6myzYEC>OYB(fMdODVSIeYnUf?wI>XBO>{JkHUcpwP8*#BCSwrH zV2MTAot?$trH@c$dCnq;c?f}qvT7j4+3UO(Zk#1*UIitV_lCWU78wXr7jG1LP2vlE zbi?D~^TcQO9c0~ul4uM@`)1q;kEClQl~^AhJ##_$`3DNl!|B2+&=cD}y`s>u1v9SF z&IhjB*w4>8e*u=8sZP{BKy{X8fUHQ)SSeKJanTef2|JeEIq4A7p_SHCvkqhJm8)!T zpi-UZsL!qz1LeJySSN zW7q-6B8WOh`CO_2lG~1&v5JlNwd!Kgb$AIo7Mdr6{C|7K=Db9q2uq)ejVxfew4r89k_O{Lq!&s5r(qv?v z25s=9Eyi-%K#2u^t}ts$EC!mc7-XieG|C$LVU7?yvb^OPBkM*|R40<#&&$n|ZTpH! zZ`F>%vZg>x`-c#1c6jfdQBD{HFV7B-M5C!Md&jY%tdb>eM2JLSnFdQVR8>6ygUK~O z3yXO&`!4Ero=)?1=^4Sxv%VyA>SZ zaS=qCCngqcpJk2_-mM@BaL>L1*!qpK+YXU|#}$Z$*SI%cV)1PH zC!1t@=iB$nA>-f)_`X>~MR+{~w|X?ULrhY*JwOSiX;6@=?JJT1`#%>CQ${Aj{MGp& z^`Z7R`acU<8S?PB2;zhx31F$JhMCK^xI8BH$iE++AOw#m4AH3yLF9?0Kq9e>ZW9R~ zb&6uUWJP5FiRaS9C1Y#~bLAx)c0N+m4SBgX<{3l8M^g08zK`xqiDf}>`SV<6fKh=wE zCQ=jgoF&}KQB3Urd@s!>au$Vy&x(p+wE#R4USoP<%z&+fdRhMY6ZE89^DK1Ux4VH?@XYCfiZ8g0K86N zVeoaIIM|2@X7>3DiV!I#?tUsBqI?t+z4Dz21aHErvx+exD)pJtt zxCr8V2+@i$adzPa*_ha`Sp+l-mL8C}TOI*O1B%S34p323!@j1()qI_vu|qUF$<(c%z4^XH63)py5i{OhX%m4rY07*qoM6N<$g20Au4FCWD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c3a1a3e2bfe81484c9d75727924afa54fd6ae4a GIT binary patch literal 5329 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c3a1a3e2bfe81484c9d75727924afa54fd6ae4a GIT binary patch literal 5329 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..853f24990feade55fd8d834e85e24bb5482ead1c GIT binary patch literal 9614 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 FV1lystsejY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..01d4f69d6a336bac482c42583f2156d92bb18db1 GIT binary patch literal 7738 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 yHM#1|>RWx6_EvrQr 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 T&wwbhLClJU!X+;+!^ zuNdKFc*>a0tJ(@^8l@E?GO%D>0A;8)D>k#`Ba`bea(0)nB5Mq|T4~0Hwbs_E@Rx95 zTv7zM&1AN|J4L}fDC8~@!ZL^@`hzn;`O*E~JWnPHoc?o5Xbj#ZLth7E2V!(|J(qVa z+RB)v7;SeoRp-g_;3u(O=)<$TD^5*B4%0x!{%ZE!0#TZY>sAwxASXRbkz7#f_1x00 zi*@X+0=)DpGk9WbXLU<5S$Vijewi2F+@%>Pv#~2TksXtdUn*rMjK=G26EIlQK=8x^QL6Jbplt_qc# zprPW+{kfx33L&f9t*#; zBi{4LyWuZUp!%YMUo~ZI9~C`xvO0Qf$!LWSWWpT$`C3GUNmm#m2Ka;>)I}E&ILj5q zXW7)A_8&mfpg*ImSJA-V`yxXEc1A`?W&97D6AezoHqFkWC*fViq2mzb5wW3ezucptG{Pov&^%t5?IokxYt_Lu}1aLK#8L}tuk zLFMRZ*%W&M^~VUH3{g}xJ2p~|m5*r`V4ZRkhBKm(lhyOrw{4#>>f`I`_QqhKWG*js zqru6zqXvoic1h`M=K=GhhVdDKMn?oRaHo11Tv92~^HGe6Fu0P!cn=*7wS>#(Ow^pG zs%pW!DeAx5-Pd$PfE|-aByRG#%(}>kl{NNNDDc5e0<|G|SGwBWXF;Ml};TcGjj zx6{<{H|9_%JMc$>u2&~)!Mgf`VF=){{JU2rQ-Wqpq33r`?^B{2M#eCBKF$C*BxcHN zMR;=B5x#{~>70!9s`nP29&#dYSwti74RiAct>557P`_QbJ!yP~$AhEIW&WG1bi?gj zsElr8n>q7xp~iUPqBb3j)D1zTC5MB4F$j~>P1krQV)blxl7wB(Zx*jsK{wQW-PmSdyb4l^s+b*+5`*R7IKoBR*Hi7eZ|6^!!KXi1 zNp|N967Bh23-pAFXwezf7=nRkcE>#l95c-!5#Wx=9C7K)w`tU(pgiUUW2>Yf_*i#; z(Aj@`5!wqP3&b=jH-D^43T?J90^WCTaD>Zip0}Mw9ZP|+YZ03in>@Tf6dTTncz8th zw+a{;otfN7{1N@^{X?IhLkfeU1a@kIxiR$60;Kt)D#EugF+5zDj(MyXP$K4df#{W} zd)HtrTQh5ZYy!q^`P~fpyR~b#@T|rE?#+iN!_?`w`%yqmoVLjSAExgShYV{$E!p*R SG3IX&ERd3>Vzt5t=>Gwz4&yce literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 37463242e8..60e98fb4c5 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -9,7 +9,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var chatModel: ChatModel - + var body: some View { if let user = chatModel.currentUser { ChatListView(user: user) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 60a8a9b925..f483f6712e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -56,12 +56,17 @@ final class ChatModel: ObservableObject { if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { chats[ix].chatItems = [cItem] if chatId != cInfo.id { - let chat = chats.remove(at: ix) - chats.insert(chat, at: 0) + withAnimation { + let chat = chats.remove(at: ix) + chats.insert(chat, at: 0) + } + if chatId != nil { + // meesage arrived to some other chat + } } } if chatId == cInfo.id { - chatItems.append(cItem) + withAnimation { chatItems.append(cItem) } } } @@ -326,8 +331,8 @@ enum CIContent: Decodable { var text: String { get { switch self { - case let .sndMsgContent(mc): return mc.string - case let .rcvMsgContent(mc): return mc.string + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text } } } @@ -338,7 +343,7 @@ enum MsgContent { case unknown(type: String, text: String) case invalid(error: String) - var string: String { + var text: String { get { switch self { case let .text(text): return text diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 0d26ad87ca..a3055c1f93 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -12,8 +12,44 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel var body: some View { - UserProfile() - UserAddress() + 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("Your SimpleX servers") { +// +// } + } + .navigationTitle("Settings") + } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index 1cd497a4d8..3764fb4587 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -15,9 +15,6 @@ struct UserAddress: View { var body: some View { VStack (alignment: .leading) { - Text("Your chat address") - .font(.title) - .padding(.bottom) Text("Your 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 { @@ -62,6 +59,7 @@ struct UserAddress: View { } } .padding() + .frame(maxHeight: .infinity, alignment: .top) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index b58648b13e..6487e25200 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -17,9 +17,6 @@ struct UserProfile: View { let user: User = chatModel.currentUser! return VStack(alignment: .leading) { - Text("Your chat profile") - .font(.title) - .padding(.bottom) Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.") .padding(.bottom) if editProfile { @@ -61,6 +58,7 @@ struct UserProfile: View { } } .padding() + .frame(maxHeight: .infinity, alignment: .top) } func saveProfile() { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ebbe134a39..88a5109902 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -263,15 +263,15 @@ 5CA059C2279559F40002BEB4 /* Shared */ = { isa = PBXGroup; children = ( - 5C764E87279CBC8E000C6508 /* Model */, 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */, - 5C2E260927A2C63500F70299 /* MyPlayground.playground */, - 5C764E7F279C7276000C6508 /* dummy.m */, 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 = ""; From dca5dc4fcee69606d13698ed4e0031e22da6966a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 3 Feb 2022 07:18:17 +0000 Subject: [PATCH 51/82] iOS version 1.0.1 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 88a5109902..3396c6b047 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -727,7 +727,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -750,7 +750,7 @@ "$(PROJECT_DIR)/Libraries/ios", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -770,7 +770,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -793,7 +793,7 @@ "$(PROJECT_DIR)/Libraries/ios", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From 08dd92b72673ff44591b2977900d85cc20f88018 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 3 Feb 2022 18:22:05 +0000 Subject: [PATCH 52/82] configure build for device/simulator --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 68 +++++++++------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3396c6b047..d76ca10e74 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -11,14 +11,13 @@ 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 */; }; + 5C116CF027ABC81C00E66D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEB27ABC81C00E66D01 /* libgmp.a */; }; + 5C116CF127ABC81C00E66D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */; }; + 5C116CF227ABC81C00E66D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CED27ABC81C00E66D01 /* libffi.a */; }; + 5C116CF327ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEE27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; + 5C116CF427ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEF27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; - 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; - 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; }; - 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; - 5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; }; - 5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; }; - 5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; }; 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 */; }; @@ -27,10 +26,6 @@ 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 */; }; - 5C44B6A027A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; - 5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; - 5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; - 5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; @@ -100,18 +95,18 @@ /* 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 = ""; }; + 5C116CEB27ABC81C00E66D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C116CED27ABC81C00E66D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C116CEE27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a"; sourceTree = ""; }; + 5C116CEF27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; sourceTree = ""; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; - 5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C1AEB81279F4A6400247F08 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; 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 = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; - 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; sourceTree = ""; }; - 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a"; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; 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; }; @@ -151,13 +146,13 @@ buildActionMask = 2147483647; files = ( 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, + 5C116CF227ABC81C00E66D01 /* libffi.a in Frameworks */, + 5C116CF427ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */, + 5C116CF127ABC81C00E66D01 /* libgmpxx.a in Frameworks */, + 5C116CF327ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, + 5C116CF027ABC81C00E66D01 /* libgmp.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - 5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */, - 5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */, - 5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */, - 5C44B6A027A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */, - 5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -167,11 +162,6 @@ files = ( 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, - 5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */, - 5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */, - 5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */, - 5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */, - 5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -218,11 +208,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C1AEB7F279F4A6400247F08 /* libffi.a */, - 5C1AEB80279F4A6400247F08 /* libgmp.a */, - 5C1AEB81279F4A6400247F08 /* libgmpxx.a */, - 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */, - 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */, + 5C116CED27ABC81C00E66D01 /* libffi.a */, + 5C116CEB27ABC81C00E66D01 /* libgmp.a */, + 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */, + 5C116CEE27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */, + 5C116CEF27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */, ); path = Libraries; sourceTree = ""; @@ -744,12 +734,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - "$(PROJECT_DIR)/Libraries/ios", - "$(PROJECT_DIR)/Libraries/sim", - ); + LIBRARY_SEARCH_PATHS = ""; + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -787,12 +774,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - "$(PROJECT_DIR)/Libraries/ios", - "$(PROJECT_DIR)/Libraries/sim", - ); + LIBRARY_SEARCH_PATHS = ""; + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; From 792486181058141986f820dc2ab353c9bb405992 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 11:12:12 +0400 Subject: [PATCH 53/82] sort chat items by id (#264) --- src/Simplex/Chat/Store.hs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 180ca2fe97..345368a689 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2146,7 +2146,7 @@ getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreErro getDirectChatLast_ db User {userId} contactId count = do contact <- ExceptT $ getContact_ db userId contactId chatItems <- liftIO getDirectChatItemsLast_ - pure $ Chat (DirectChat contact) (sortOn chatItemTs chatItems) + pure $ Chat (DirectChat contact) (reverse chatItems) where getDirectChatItemsLast_ :: IO [CChatItem 'CTDirect] getDirectChatItemsLast_ = do @@ -2158,7 +2158,7 @@ getDirectChatLast_ db User {userId} contactId count = do SELECT chat_item_id, item_ts, item_content, item_text, created_at FROM chat_items WHERE user_id = ? AND contact_id = ? - ORDER BY item_ts DESC + ORDER BY chat_item_id DESC LIMIT ? |] (userId, contactId, count) @@ -2179,7 +2179,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do SELECT chat_item_id, item_ts, item_content, item_text, created_at FROM chat_items WHERE user_id = ? AND contact_id = ? AND chat_item_id > ? - ORDER BY item_ts ASC + ORDER BY chat_item_id ASC LIMIT ? |] (userId, contactId, afterChatItemId, count) @@ -2188,7 +2188,7 @@ getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> E getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do contact <- ExceptT $ getContact_ db userId contactId chatItems <- liftIO getDirectChatItemsBefore_ - pure $ Chat (DirectChat contact) (sortOn chatItemTs chatItems) + pure $ Chat (DirectChat contact) (reverse chatItems) where getDirectChatItemsBefore_ :: IO [CChatItem 'CTDirect] getDirectChatItemsBefore_ = do @@ -2200,7 +2200,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do SELECT chat_item_id, item_ts, item_content, item_text, created_at FROM chat_items WHERE user_id = ? AND contact_id = ? AND chat_item_id < ? - ORDER BY item_ts DESC + ORDER BY chat_item_id DESC LIMIT ? |] (userId, contactId, beforeChatItemId, count) @@ -2255,7 +2255,7 @@ getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError getGroupChatLast_ db user@User {userId, userContactId} groupId count = do groupInfo <- ExceptT $ getGroupInfo_ db user groupId chatItems <- ExceptT getGroupChatItemsLast_ - pure $ Chat (GroupChat groupInfo) (sortOn chatItemTs chatItems) + pure $ Chat (GroupChat groupInfo) (reverse chatItems) where getGroupChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsLast_ = do @@ -2275,7 +2275,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do 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 item_ts DESC + ORDER BY ci.chat_item_id DESC LIMIT ? |] (userId, groupId, count) @@ -2304,7 +2304,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId 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 item_ts ASC + ORDER BY ci.chat_item_id ASC LIMIT ? |] (userId, groupId, afterChatItemId, count) @@ -2313,7 +2313,7 @@ getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> Ex getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemId count = do groupInfo <- ExceptT $ getGroupInfo_ db user groupId chatItems <- ExceptT getGroupChatItemsBefore_ - pure $ Chat (GroupChat groupInfo) (sortOn chatItemTs chatItems) + pure $ Chat (GroupChat groupInfo) (reverse chatItems) where getGroupChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsBefore_ = do @@ -2333,7 +2333,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI 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 item_ts DESC + ORDER BY ci.chat_item_id DESC LIMIT ? |] (userId, groupId, beforeChatItemId, count) From 565bc708437f575a9621928dc1502b2cd40919f3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 08:02:48 +0000 Subject: [PATCH 54/82] sync commands --- src/Simplex/Chat.hs | 24 ++++++++++++------------ src/Simplex/Chat/View.hs | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8b8e867d05..58b4e28074 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -357,19 +357,19 @@ processChatCommand user@User {userId, profile} = \case QuitChat -> liftIO exitSuccess ShowVersion -> pure CRVersionInfo where - procCmd :: m ChatResponse -> m ChatResponse - procCmd action = do - -- below code would make command responses asynchronous where they can be slow - -- in View.hs `r'` should be defined as `id` in this case - 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" + -- 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 = action + -- 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 :: ConnectionRequestUri c -> ChatMsgEvent -> m () connect cReq msg = do connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 527ce3bacf..69dc24282c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -118,8 +118,8 @@ responseToView cmd = \case api = (highlight cmd :) r = (plain cmd :) -- this function should be `r` for "synchronous", `id` for "asynchronous" command responses - -- r' = r - r' = id + -- r' = id + r' = r viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] viewChatItem chat (ChatItem cd meta content) = case (chat, cd) of From d07ce0b8f494f5df23287e96309615294920d4d9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 08:15:25 +0000 Subject: [PATCH 55/82] use 8 byte characters, as encoding is handled elsewhere --- src/Simplex/Chat/Mobile.hs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index cdcf40cdd3..e5bf539aef 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -40,18 +40,18 @@ foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> -- | creates or connects to chat store cChatInitStore :: CString -> IO (StablePtr ChatStore) -cChatInitStore fp = peekCString fp >>= chatInitStore >>= newStablePtr +cChatInitStore fp = peekCAString fp >>= chatInitStore >>= newStablePtr -- | returns JSON in the form `{"user": }` or `{}` in case there is no active user (to show dialog to enter displayName/fullName) cChatGetUser :: StablePtr ChatStore -> IO CJSONString -cChatGetUser cc = deRefStablePtr cc >>= chatGetUser >>= newCString +cChatGetUser cc = deRefStablePtr cc >>= chatGetUser >>= newCAString -- | accepts Profile JSON, returns JSON `{"user": }` or `{"error": ""}` cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString cChatCreateUser cPtr profileCJson = do c <- deRefStablePtr cPtr - p <- peekCString profileCJson - newCString =<< chatCreateUser c p + p <- peekCAString profileCJson + newCAString =<< chatCreateUser c p -- | this function starts chat - it cannot be started during initialization right now, as it cannot work without user (to be fixed later) cChatStart :: StablePtr ChatStore -> IO (StablePtr ChatController) @@ -61,12 +61,12 @@ cChatStart st = deRefStablePtr st >>= chatStart >>= newStablePtr cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString cChatSendCmd cPtr cCmd = do c <- deRefStablePtr cPtr - cmd <- peekCString cCmd - newCString =<< chatSendCmd c cmd + cmd <- peekCAString cCmd + newCAString =<< chatSendCmd c cmd -- | receive message from chat (blocking) cChatRecvMsg :: StablePtr ChatController -> IO CJSONString -cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCString +cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString mobileChatOpts :: ChatOpts mobileChatOpts = From 9969606432d0f2e360ffd138478c5d138fd52860 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 14:30:00 +0400 Subject: [PATCH 56/82] fix utf8 encoding when writing to database --- src/Simplex/Chat/Messages.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 166ceb340d..edf42d265e 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -35,6 +35,7 @@ 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.Chat.Util (safeDecodeUtf8) data ChatType = CTDirect | CTGroup | CTContactRequest deriving (Show, Generic) @@ -215,7 +216,7 @@ ciContentToText = \case CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName instance ToField (CIContent d) where - toField = toField . decodeLatin1 . LB.toStrict . J.encode + toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode instance ToJSON (CIContent d) where toJSON = J.toJSON . jsonCIContent From c34eddb82a2961e0eba253cc296bab241c37de0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 12:41:43 +0000 Subject: [PATCH 57/82] fix utf8 encoding for C API requests --- src/Simplex/Chat.hs | 5 ++--- src/Simplex/Chat/Mobile.hs | 2 +- src/Simplex/Chat/Terminal/Input.hs | 3 ++- tests/ChatTests.hs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 58b4e28074..3e4fde6f23 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -34,7 +34,6 @@ 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) @@ -109,8 +108,8 @@ withLock lock = (void . atomically $ takeTMVar lock) (atomically $ putTMVar lock ()) -execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => String -> m ChatResponse -execChatCommand s = case parseAll chatCommandP . B.dropWhileEnd isSpace . encodeUtf8 $ T.pack s of +execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse +execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of Left e -> pure . CRChatError . ChatError $ CECommandError e Right cmd -> do ChatController {chatLock = l, smpAgent = a, currentUser} <- ask diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index e5bf539aef..2803293638 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -117,7 +117,7 @@ chatStart ChatStore {dbFilePrefix, chatStore} = do pure cc chatSendCmd :: ChatController -> String -> IO JSONString -chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc +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) diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 3670acb438..11e43b3253 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -9,6 +9,7 @@ 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.Output @@ -29,7 +30,7 @@ runInputLoop ct = do q <- asks inputQ forever $ do s <- atomically $ readTBQueue q - r <- execChatCommand s + r <- execChatCommand . encodeUtf8 $ T.pack s liftIO . printToTerminal ct $ responseToView s r runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 71e1be0b59..6ac2ba4f3d 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -65,8 +65,8 @@ testAddContact = concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - alice #> "@bob hello" - bob <# "alice> hello" + alice #> "@bob hello 🙂" + bob <# "alice> hello 🙂" bob #> "@alice hi" alice <# "bob> hi" -- test adding the same contact one more time - local name will be different From 214ecf605b0b0b4cba965e4553b59ae285793385 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 16:31:08 +0000 Subject: [PATCH 58/82] minor UI improvements (#267) --- apps/ios/Shared/Model/ChatModel.swift | 13 +++--- apps/ios/Shared/Views/Chat/ChatView.swift | 8 ++-- .../Shared/Views/ChatList/ChatListView.swift | 6 --- apps/ios/Shared/Views/TerminalView.swift | 41 ++++++++++++------- .../Views/UserSettings/SettingsView.swift | 10 ++++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++----- 6 files changed, 56 insertions(+), 42 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f483f6712e..2e106cb506 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -55,18 +55,15 @@ final class ChatModel: ObservableObject { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { chats[ix].chatItems = [cItem] - if chatId != cInfo.id { - withAnimation { - let chat = chats.remove(at: ix) - chats.insert(chat, at: 0) - } - if chatId != nil { - // meesage arrived to some other chat - } + withAnimation { + let chat = chats.remove(at: ix) + chats.insert(chat, at: 0) } } if chatId == cInfo.id { withAnimation { chatItems.append(cItem) } + } else if chatId != nil { + // meesage arrived to some other chat, show notification } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 77d0776339..d6e8849b1f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -17,7 +17,7 @@ struct ChatView: View { VStack { ScrollViewReader { proxy in ScrollView { - VStack { + VStack(spacing: 5) { ForEach(chatModel.chatItems, id: \.id) { ChatItemView(chatItem: $0) } @@ -35,12 +35,14 @@ struct ChatView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { chatModel.chatId = nil } label: { - Image(systemName: "chevron.backward") + HStack(spacing: 4) { + Image(systemName: "chevron.backward") + Text("Chats") + } } } } .navigationBarBackButtonHidden(true) - } func scrollToBottom(_ proxy: ScrollViewProxy) { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index c7befab266..3f98418f77 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -26,12 +26,6 @@ struct ChatListView: View { NavigationView { List { - NavigationLink { - TerminalView() - } label: { - Text("Terminal") - } - ForEach(chatModel.chats) { chat in ChatListNavLink(chat: chat) } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 55f9bbaeb7..ee7052f057 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -14,26 +14,39 @@ struct TerminalView: View { var body: some View { VStack { - ScrollView { - LazyVStack { - ForEach(chatModel.terminalItems) { item in - NavigationLink { - ScrollView { - Text(item.details) - .textSelection(.enabled) + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + ForEach(chatModel.terminalItems) { item in + NavigationLink { + ScrollView { + Text(item.details) + .textSelection(.enabled) + } + } label: { + Text(item.label) + .frame(width: 360, height: 30, alignment: .leading) } - } label: { - Text(item.label) - .frame(width: 360, height: 30, alignment: .leading) } + .onAppear { scrollToBottom(proxy) } + .onChange(of: chatModel.terminalItems.count) { _ in scrollToBottom(proxy) } } } + + Spacer() + + SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } - .navigationViewStyle(.stack) + } + .navigationViewStyle(.stack) + .navigationTitle("Chat console") + } - Spacer() - - SendMessageView(sendMessage: sendMessage, inProgress: inProgress) + func scrollToBottom(_ proxy: ScrollViewProxy) { + if let id = chatModel.terminalItems.last?.id { + withAnimation { + proxy.scrollTo(id, anchor: .bottom) + } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a3055c1f93..a9b1ed3bdd 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -44,11 +44,19 @@ struct SettingsView: View { } } + Section("Develop") { + NavigationLink { + TerminalView() + } label: { + Text("Chat console") + } + } + // Section("Your SimpleX servers") { // // } } - .navigationTitle("Settings") + .navigationTitle("Your settings") } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d76ca10e74..91c57f5d57 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -14,8 +14,6 @@ 5C116CF027ABC81C00E66D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEB27ABC81C00E66D01 /* libgmp.a */; }; 5C116CF127ABC81C00E66D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */; }; 5C116CF227ABC81C00E66D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CED27ABC81C00E66D01 /* libffi.a */; }; - 5C116CF327ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEE27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; }; - 5C116CF427ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEF27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; }; 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 */; }; @@ -73,6 +71,8 @@ 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 */; }; + 5CE4406F27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */; }; + 5CE4407027AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -98,8 +98,6 @@ 5C116CEB27ABC81C00E66D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C116CED27ABC81C00E66D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C116CEE27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a"; sourceTree = ""; }; - 5C116CEF27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; 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; }; @@ -138,6 +136,8 @@ 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 = ""; }; + 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a"; sourceTree = ""; }; + 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -145,11 +145,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CE4406F27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a in Frameworks */, + 5CE4407027AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, 5C116CF227ABC81C00E66D01 /* libffi.a in Frameworks */, - 5C116CF427ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */, 5C116CF127ABC81C00E66D01 /* libgmpxx.a in Frameworks */, - 5C116CF327ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, 5C116CF027ABC81C00E66D01 /* libgmp.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, @@ -211,8 +211,8 @@ 5C116CED27ABC81C00E66D01 /* libffi.a */, 5C116CEB27ABC81C00E66D01 /* libgmp.a */, 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */, - 5C116CEE27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */, - 5C116CEF27ABC81C00E66D01 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */, + 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */, + 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */, ); path = Libraries; sourceTree = ""; @@ -734,7 +734,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ""; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.0.1; @@ -774,7 +774,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ""; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.0.1; From e424e9328b664b5cd4eaf7b31b99f6c7331efb6b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 4 Feb 2022 22:13:52 +0000 Subject: [PATCH 59/82] large emojis, full contact names, contact createdAt, process profile updates, etc. (#268) --- apps/ios/Shared/Model/ChatModel.swift | 40 +++++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 10 ++- .../Views/Chat/ChatItem/EmojiItemView.swift | 43 ++++++++++++ .../Views/Chat/ChatItem/TextItemView.swift | 55 +++++++++++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 41 +++-------- apps/ios/Shared/Views/Chat/ChatView.swift | 32 +++++---- apps/ios/Shared/Views/Chat/Emoji.swift | 27 ++++++++ .../Views/ChatList/ChatListNavLink.swift | 4 +- .../Shared/Views/ChatList/ChatListView.swift | 4 +- .../Views/ChatList/ChatPreviewView.swift | 68 +++++++++++-------- .../Views/ChatList/ContactRequestView.swift | 4 +- apps/ios/Shared/Views/TerminalView.swift | 8 +-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 26 +++++++ 13 files changed, 270 insertions(+), 92 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift create mode 100644 apps/ios/Shared/Views/Chat/Emoji.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2e106cb506..7b28d8c715 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -126,12 +126,26 @@ enum ChatInfo: Identifiable, Decodable { 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)" + case let .direct(contact): return contact.localDisplayName + case let .group(groupInfo): return groupInfo.localDisplayName + case let .contactRequest(contactRequest): return contactRequest.localDisplayName } } } + + var fullName: String { + get { + switch self { + case let .direct(contact): return contact.profile.fullName + case let .group(groupInfo): return groupInfo.groupProfile.fullName + case let .contactRequest(contactRequest): return contactRequest.profile.fullName + } + } + } + + var chatViewName: String { + get { localDisplayName + (fullName == "" || fullName == localDisplayName ? "" : " / \(fullName)") } + } var id: String { get { @@ -162,6 +176,14 @@ enum ChatInfo: Identifiable, Decodable { } } } + + 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 + } + } } let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact) @@ -200,6 +222,7 @@ struct Contact: Identifiable, Decodable { var profile: Profile var activeConn: Connection var viaGroup: Int64? + var createdAt: Date var id: String { get { "@\(contactId)" } } var apiId: Int64 { get { contactId } } @@ -210,7 +233,8 @@ let sampleContact = Contact( contactId: 1, localDisplayName: "alice", profile: sampleProfile, - activeConn: sampleConnection + activeConn: sampleConnection, + createdAt: .now ) struct Connection: Decodable { @@ -223,6 +247,7 @@ struct UserContactRequest: Decodable { var contactRequestId: Int64 var localDisplayName: ContactName var profile: Profile + var createdAt: Date var id: String { get { "<@\(contactRequestId)" } } @@ -232,13 +257,15 @@ struct UserContactRequest: Decodable { let sampleContactRequest = UserContactRequest( contactRequestId: 1, localDisplayName: "alice", - profile: sampleProfile + profile: sampleProfile, + createdAt: .now ) struct GroupInfo: Identifiable, Decodable { var groupId: Int64 var localDisplayName: GroupName var groupProfile: GroupProfile + var createdAt: Date var id: String { get { "#\(groupId)" } } @@ -248,7 +275,8 @@ struct GroupInfo: Identifiable, Decodable { let sampleGroupInfo = GroupInfo( groupId: 1, localDisplayName: "team", - groupProfile: sampleGroupProfile + groupProfile: sampleGroupProfile, + createdAt: .now ) struct GroupProfile: Codable { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 240c565fbb..9d86361960 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -84,6 +84,7 @@ enum ChatResponse: Decodable, Error { case receivedContactRequest(contactRequest: UserContactRequest) case acceptingContactRequest(contact: Contact) case contactRequestRejected + case contactUpdated(toContact: Contact) case newChatItem(chatItem: AChatItem) case chatCmdError(chatError: ChatError) @@ -106,6 +107,7 @@ enum ChatResponse: Decodable, Error { case .receivedContactRequest: return "receivedContactRequest" case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" + case .contactUpdated: return "contactUpdated" case .newChatItem: return "newChatItem" case .chatCmdError: return "chatCmdError" } @@ -131,6 +133,7 @@ enum ChatResponse: Decodable, Error { 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 .newChatItem(chatItem): return String(describing: chatItem) case let .chatCmdError(chatError): return String(describing: chatError) } @@ -293,7 +296,7 @@ func apiRejectContactRequest(contactReqId: Int64) throws { func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { DispatchQueue.main.async { - chatModel.terminalItems.append(.resp(Date.now, res)) + chatModel.terminalItems.append(.resp(.now, res)) switch res { case let .contactConnected(contact): let cInfo = ChatInfo.direct(contact: contact) @@ -307,6 +310,11 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), chatItems: [] )) + case let .contactUpdated(toContact): + let cInfo = ChatInfo.direct(contact: toContact) + if chatModel.hasChat(toContact.id) { + chatModel.updateChatInfo(cInfo) + } case let .newChatItem(aChatItem): chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem) default: 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..04abdfd9fe --- /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) + .font(Font.custom("Emoji", size: 48, relativeTo: .largeTitle)) + .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: chatItemSample(1, .directSnd, .now, "🙂")) + EmojiItemView(chatItem: chatItemSample(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..a9a25b01f2 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -0,0 +1,55 @@ +// +// TextItemView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 04/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct TextItemView: View { + var chatItem: ChatItem + var width: CGFloat + + var body: some View { + let sent = chatItem.chatDir.sent + let minWidth = min(200, width) + let maxWidth = min(300, width * 0.78) + + return VStack { + Text(chatItem.content.text) + .padding(.top, 8) + .padding(.horizontal, 12) + .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .leading) + .foregroundColor(sent ? .white : .primary) + .textSelection(.enabled) + Text(getDateFormatter().string(from: chatItem.meta.itemTs)) + .font(.caption) + .foregroundColor(sent ? .white : .secondary) + .padding(.bottom, 8) + .padding(.horizontal, 12) + .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .trailing) + } + .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) + .cornerRadius(10) + .padding(.horizontal) + .frame( + minWidth: 200, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: sent ? .trailing : .leading + ) + } +} + +struct TextItemView_Previews: PreviewProvider { + static var previews: some View { + Group{ + TextItemView(chatItem: chatItemSample(1, .directSnd, .now, "hello"), width: 360) + TextItemView(chatItem: chatItemSample(2, .directRcv, .now, "hello there too"), width: 360) + } + .previewLayout(.fixed(width: 360, height: 70)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 039e64ae72..5375b3304d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -12,36 +12,14 @@ private var dateFormatter: DateFormatter? struct ChatItemView: View { var chatItem: ChatItem + var width: CGFloat var body: some View { - let sent = chatItem.chatDir.sent - - return VStack { - Group { - Text(chatItem.content.text) - .padding(.top, 8) - .padding(.horizontal, 12) - .frame(minWidth: 200, maxWidth: 300, alignment: .leading) - .foregroundColor(sent ? .white : .primary) - .textSelection(.enabled) - Text(getDateFormatter().string(from: chatItem.meta.itemTs)) - .font(.subheadline) - .foregroundColor(sent ? .white : .secondary) - .padding(.bottom, 8) - .padding(.horizontal, 12) - .frame(minWidth: 200, maxWidth: 300, alignment: .trailing) - } + if (isShortEmoji(chatItem.content.text)) { + EmojiItemView(chatItem: chatItem) + } else { + TextItemView(chatItem: chatItem, width: width) } - .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) - .cornerRadius(10) - .padding(.horizontal) - .frame( - minWidth: 200, - maxWidth: .infinity, - minHeight: 0, - maxHeight: .infinity, - alignment: sent ? .trailing : .leading - ) } } @@ -56,9 +34,12 @@ func getDateFormatter() -> DateFormatter { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chatItem: chatItemSample(1, .directSnd, Date.now, "hello")) - ChatItemView(chatItem: chatItemSample(2, .directRcv, Date.now, "hello there too")) + ChatItemView(chatItem: chatItemSample(1, .directSnd, .now, "hello"), width: 360) + ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "hello there too"), width: 360) + ChatItemView(chatItem: chatItemSample(1, .directSnd, .now, "🙂"), width: 360) + ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "👍👍👍"), width: 360) + ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "👍👍👍👍"), width: 360) } - .previewLayout(.fixed(width: 300, height: 70)) + .previewLayout(.fixed(width: 360, height: 70)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index d6e8849b1f..ce186a3986 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -15,14 +15,16 @@ struct ChatView: View { var body: some View { VStack { - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 5) { - ForEach(chatModel.chatItems, id: \.id) { - ChatItemView(chatItem: $0) + GeometryReader { g in + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 5) { + ForEach(chatModel.chatItems, id: \.id) { + ChatItemView(chatItem: $0, width: g.size.width) + } + .onAppear { scrollToBottom(proxy) } + .onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) } } - .onAppear { scrollToBottom(proxy) } - .onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) } } } } @@ -31,7 +33,7 @@ struct ChatView: View { SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } - .navigationTitle(chatInfo.localDisplayName) + .navigationTitle(chatInfo.chatViewName) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { chatModel.chatId = nil } label: { @@ -68,13 +70,13 @@ struct ChatView_Previews: PreviewProvider { let chatModel = ChatModel() chatModel.chatId = "@1" chatModel.chatItems = [ - chatItemSample(1, .directSnd, Date.now, "hello"), - chatItemSample(2, .directRcv, Date.now, "hi"), - chatItemSample(3, .directRcv, Date.now, "hi there"), - chatItemSample(4, .directRcv, Date.now, "hello again"), - chatItemSample(5, .directSnd, Date.now, "hi there!!!"), - chatItemSample(6, .directSnd, Date.now, "how are you?"), - chatItemSample(7, .directSnd, Date.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.") + chatItemSample(1, .directSnd, .now, "hello"), + chatItemSample(2, .directRcv, .now, "hi"), + chatItemSample(3, .directRcv, .now, "hi there"), + chatItemSample(4, .directRcv, .now, "hello again"), + chatItemSample(5, .directSnd, .now, "hi there!!!"), + chatItemSample(6, .directSnd, .now, "how are you?"), + chatItemSample(7, .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(chatInfo: sampleDirectChatInfo) .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..034c12c837 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Emoji.swift @@ -0,0 +1,27 @@ +// +// Emoji.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 04/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +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 <= 3 && s.allSatisfy(isEmoji) +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 7a4317610b..7899627f55 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -176,11 +176,11 @@ struct ChatListNavLink_Previews: PreviewProvider { return Group { ChatListNavLink(chat: Chat( chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + chatItems: [chatItemSample(1, .directSnd, .now, "hello")] )) ChatListNavLink(chat: Chat( chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + chatItems: [chatItemSample(1, .directSnd, .now, "hello")] )) ChatListNavLink(chat: Chat( chatInfo: sampleContactRequestChatInfo, diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 3f98418f77..5e24ae3b3a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -92,11 +92,11 @@ struct ChatListView_Previews: PreviewProvider { chatModel.chats = [ Chat( chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + chatItems: [chatItemSample(1, .directSnd, .now, "hello")] ), Chat( chatInfo: sampleGroupChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.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.")] + chatItems: [chatItemSample(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: sampleContactRequestChatInfo, diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 7447b8cd83..5f4244a98c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -13,38 +13,46 @@ struct ChatPreviewView: View { var body: some View { let cItem = chat.chatItems.last - return VStack(spacing: 4) { - HStack(alignment: .top) { - Text(chat.chatInfo.localDisplayName) - .font(.title3) - .fontWeight(.bold) - .padding(.leading, 8) - .padding(.top, 4) - .frame(maxHeight: .infinity, alignment: .topLeading) - Spacer() - if let cItem = cItem { - Text(getDateFormatter().string(from: cItem.meta.itemTs)) + 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 HStack(spacing: 8) { + Image(systemName: iconName) + .resizable() + .foregroundColor(Color(uiColor: .secondarySystemBackground)) + .frame(width: 63, height: 63) + .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) - .padding(.trailing, 8) - .padding(.top, 4) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) } + .padding(.top, 4) + .padding(.horizontal, 8) + + if let cItem = cItem { + Text(cItem.content.text) + .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.connected { + Text("Connecting...") + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + } } - if let cItem = cItem { - Text(cItem.content.text) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding([.leading, .trailing], 8) - .padding(.bottom, 4) - .padding(.top, 1) - } -// else if case let .direct(contact) = chatPreview.chatInfo, !contact.connected { -// Text("Connecting...") -// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) -// .padding([.leading, .trailing], 8) -// .padding(.bottom, 4) -// .padding(.top, 1) -// } } } } @@ -58,13 +66,13 @@ struct ChatPreviewView_Previews: PreviewProvider { )) ChatPreviewView(chat: Chat( chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")] + chatItems: [chatItemSample(1, .directSnd, .now, "hello")] )) ChatPreviewView(chat: Chat( chatInfo: sampleGroupChatInfo, - chatItems: [chatItemSample(1, .directSnd, Date.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.")] + chatItems: [chatItemSample(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: 80)) + .previewLayout(.fixed(width: 360, height: 78)) } } diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 140c398d91..3a9e945009 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -14,7 +14,7 @@ struct ContactRequestView: View { var body: some View { return VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { - Text("@\(contactRequest.localDisplayName)") + Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName) .font(.title3) .fontWeight(.bold) .foregroundColor(.blue) @@ -22,7 +22,7 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - Text("12:34")// getDateFormatter().string(from: cItem.meta.itemTs)) + Text(getDateFormatter().string(from: contactRequest.createdAt)) .font(.subheadline) .padding(.trailing, 28) .padding(.top, 4) diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index ee7052f057..0170786053 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -52,14 +52,14 @@ struct TerminalView: View { func sendMessage(_ cmdStr: String) { let cmd = ChatCommand.string(cmdStr) - chatModel.terminalItems.append(.cmd(Date.now, cmd)) + chatModel.terminalItems.append(.cmd(.now, cmd)) DispatchQueue.global().async { inProgress = true do { let r = try chatSendCmd(cmd) DispatchQueue.main.async { - chatModel.terminalItems.append(.resp(Date.now, r)) + chatModel.terminalItems.append(.resp(.now, r)) } } catch { print(error) @@ -73,8 +73,8 @@ struct TerminalView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.terminalItems = [ - .resp(Date.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), - .resp(Date.now, ChatResponse.response(type: "newChatItem", json: "{}")) + .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), + .resp(.now, ChatResponse.response(type: "newChatItem", json: "{}")) ] return NavigationView { TerminalView() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 91c57f5d57..28a608d78d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -73,6 +73,12 @@ 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CE4406F27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */; }; 5CE4407027AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */; }; + 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 */ @@ -138,6 +144,9 @@ 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a"; sourceTree = ""; }; 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a"; 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 */ @@ -198,9 +207,11 @@ 5C5F4AC227A5E9AF00B51EF1 /* Chat */ = { isa = PBXGroup; children = ( + 5CE4407427ADB657007B033A /* ChatItem */, 5C2E260E27A30FDC00F70299 /* ChatView.swift */, 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, + 5CE4407127ADB1D0007B033A /* Emoji.swift */, ); path = Chat; sourceTree = ""; @@ -337,6 +348,15 @@ path = ChatList; sourceTree = ""; }; + 5CE4407427ADB657007B033A /* ChatItem */ = { + isa = PBXGroup; + children = ( + 5CE4407527ADB66A007B033A /* TextItemView.swift */, + 5CE4407827ADB701007B033A /* EmojiItemView.swift */, + ); + path = ChatItem; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -506,7 +526,9 @@ 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 */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, @@ -528,6 +550,7 @@ 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; @@ -538,7 +561,9 @@ 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 */, 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, @@ -560,6 +585,7 @@ 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; From 3d137995d8b1f6a522ed9b8028b724846846895f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 5 Feb 2022 14:24:23 +0000 Subject: [PATCH 60/82] multiline message entry field (#270) --- apps/ios/Shared/Model/ChatModel.swift | 14 +++- .../Views/Chat/ChatItem/EmojiItemView.swift | 4 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 4 +- apps/ios/Shared/Views/Chat/ChatView.swift | 6 +- apps/ios/Shared/Views/Chat/Emoji.swift | 5 +- .../Shared/Views/Chat/SendMessageView.swift | 78 ++++++++++++++----- .../Views/ChatList/ContactRequestView.swift | 49 +++++++----- 7 files changed, 112 insertions(+), 48 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7b28d8c715..ff3e78ec8b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -55,9 +55,12 @@ final class ChatModel: ObservableObject { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { chats[ix].chatItems = [cItem] - withAnimation { - let chat = chats.remove(at: ix) - chats.insert(chat, at: 0) + if ix > 0 { + if chatId == nil { + withAnimation { popChat(ix) } + } else { + DispatchQueue.main.async { self.popChat(ix) } + } } } if chatId == cInfo.id { @@ -67,6 +70,11 @@ final class ChatModel: ObservableObject { } } + 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 }) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 04abdfd9fe..b474feee13 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -15,8 +15,8 @@ struct EmojiItemView: View { let sent = chatItem.chatDir.sent VStack { - Text(chatItem.content.text) - .font(Font.custom("Emoji", size: 48, relativeTo: .largeTitle)) + Text(chatItem.content.text.trimmingCharacters(in: .whitespaces)) + .font(emojiFont) .padding(.top, 8) .padding(.horizontal, 6) .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5375b3304d..01159d7fda 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -37,8 +37,8 @@ struct ChatItemView_Previews: PreviewProvider { ChatItemView(chatItem: chatItemSample(1, .directSnd, .now, "hello"), width: 360) ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "hello there too"), width: 360) ChatItemView(chatItem: chatItemSample(1, .directSnd, .now, "🙂"), width: 360) - ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "👍👍👍"), width: 360) - ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "👍👍👍👍"), width: 360) + ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), width: 360) + ChatItemView(chatItem: chatItemSample(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 index ce186a3986..04b40f4995 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -45,6 +45,9 @@ struct ChatView: View { } } .navigationBarBackButtonHidden(true) + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } func scrollToBottom(_ proxy: ScrollViewProxy) { @@ -76,7 +79,8 @@ struct ChatView_Previews: PreviewProvider { chatItemSample(4, .directRcv, .now, "hello again"), chatItemSample(5, .directSnd, .now, "hi there!!!"), chatItemSample(6, .directSnd, .now, "how are you?"), - chatItemSample(7, .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.") + chatItemSample(7, .directSnd, .now, "👍👍👍👍"), + chatItemSample(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(chatInfo: sampleDirectChatInfo) .environmentObject(chatModel) diff --git a/apps/ios/Shared/Views/Chat/Emoji.swift b/apps/ios/Shared/Views/Chat/Emoji.swift index 034c12c837..479336395e 100644 --- a/apps/ios/Shared/Views/Chat/Emoji.swift +++ b/apps/ios/Shared/Views/Chat/Emoji.swift @@ -7,6 +7,7 @@ // import Foundation +import SwiftUI private func isSimpleEmoji(_ c: Character) -> Bool { guard let firstScalar = c.unicodeScalars.first else { return false } @@ -23,5 +24,7 @@ func isEmoji(_ c: Character) -> Bool { func isShortEmoji(_ str: String) -> Bool { let s = str.trimmingCharacters(in: .whitespaces) - return s.count <= 3 && s.allSatisfy(isEmoji) + 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 index 21e69ab84d..d5d075b305 100644 --- a/apps/ios/Shared/Views/Chat/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/SendMessageView.swift @@ -11,36 +11,78 @@ import SwiftUI struct SendMessageView: View { var sendMessage: (String) -> Void var inProgress: Bool = false - @State var command: String = "" + @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 + @State private var teHeight: CGFloat = 42 + @State private var teFont: Font = .body + var maxHeight: CGFloat = 360 + var minHeight: CGFloat = 37 var body: some View { - HStack { - TextField("Message...", text: $command) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .onSubmit(submit) + 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) + .font(teFont) + .textInputAutocapitalization(.never) + .padding(.horizontal, 5) + .allowsTightening(false) + .frame(height: teHeight) + } - if (inProgress) { - ProgressView() - .frame(width: 40, height: 20, alignment: .center) - } else { - Button("Send", action :submit) - .disabled(command.isEmpty) + 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) } - .frame(minHeight: 30) - .padding(12) + .padding(.horizontal, 12) + .padding(.vertical, 8) } func submit() { - sendMessage(command) - command = "" + 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 { - SendMessageView(sendMessage: { print ($0) }) + VStack { + Text("") + Spacer(minLength: 0) + SendMessageView(sendMessage: { print ($0) }) + } } } diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 3a9e945009..8447a253f3 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -12,28 +12,35 @@ struct ContactRequestView: View { var contactRequest: UserContactRequest var body: some View { - return 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) + 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) } - Text("wants to connect to you!") - .frame(minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding([.leading, .trailing], 8) - .padding(.bottom, 4) - .padding(.top, 1) } } } From 67dbdcd25724989d53ffd0625e183fad0b64bc93 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 5 Feb 2022 20:10:47 +0000 Subject: [PATCH 61/82] contact and server connection info (#271) --- apps/ios/Shared/Model/ChatModel.swift | 58 ++++++- apps/ios/Shared/Model/SimpleXAPI.swift | 156 +++++++++++++++++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 48 ++++++ apps/ios/Shared/Views/Chat/ChatView.swift | 31 +++- .../Views/ChatList/ChatListNavLink.swift | 4 +- .../Views/ChatList/ChatPreviewView.swift | 27 +-- .../Shared/Views/Helpers/ChatInfoImage.swift | 33 ++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 +++ 8 files changed, 347 insertions(+), 30 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatInfoView.swift create mode 100644 apps/ios/Shared/Views/Helpers/ChatInfoImage.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ff3e78ec8b..d8709e3b50 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -31,6 +31,10 @@ final class ChatModel: ObservableObject { 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) @@ -38,11 +42,26 @@ final class ChatModel: ObservableObject { } func updateChatInfo(_ cInfo: ChatInfo) { - if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { + 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 @@ -203,6 +222,39 @@ let sampleContactRequestChatInfo = ChatInfo.contactRequest(contactRequest: sampl 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 "Connected to contact's server" + case let .error(err): return "Connecting to contact's server… (error: \(err))" + default: return "Connecting to contact's server…" + } + } + } + + 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 @@ -231,10 +283,10 @@ struct Contact: Identifiable, Decodable { var activeConn: Connection var viaGroup: Int64? var createdAt: Date - + var id: String { get { "@\(contactId)" } } var apiId: Int64 { get { contactId } } - var connected: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } + var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } } let sampleContact = Contact( diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9d86361960..58d47066f3 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -85,6 +85,9 @@ enum ChatResponse: Decodable, Error { 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 newChatItem(chatItem: AChatItem) case chatCmdError(chatError: ChatError) @@ -108,6 +111,9 @@ enum ChatResponse: Decodable, Error { 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 .newChatItem: return "newChatItem" case .chatCmdError: return "chatCmdError" } @@ -134,6 +140,9 @@ enum ChatResponse: Decodable, Error { 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 .newChatItem(chatItem): return String(describing: chatItem) case let .chatCmdError(chatError): return String(describing: chatError) } @@ -299,12 +308,8 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { chatModel.terminalItems.append(.resp(.now, res)) switch res { case let .contactConnected(contact): - let cInfo = ChatInfo.direct(contact: contact) - if chatModel.hasChat(contact.id) { - chatModel.updateChatInfo(cInfo) - } else { - chatModel.addChat(Chat(chatInfo: cInfo, chatItems: [])) - } + chatModel.updateContact(contact) + chatModel.updateNetworkStatus(contact, .connected) case let .receivedContactRequest(contactRequest): chatModel.addChat(Chat( chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), @@ -315,6 +320,21 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { 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): chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem) default: @@ -403,15 +423,135 @@ private func encodeCJSON(_ value: T) -> [CChar] { enum ChatError: Decodable { case error(errorType: ChatErrorType) + case errorMessage(errorMessage: String) + case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) - // TODO other error cases + case errorNotImplemented } enum ChatErrorType: Decodable { + case groupUserRole case invalidConnReq + case contactGroups(contact: Contact, groupNames: [GroupName]) + 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 - // TODO other error cases + 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/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift new file mode 100644 index 0000000000..e1cee27f3a --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -0,0 +1,48 @@ +// +// ChatInfoView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 05/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatInfoView: View { + @ObservedObject var chat: Chat + + 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 .direct = chat.chatInfo { + HStack { + serverImage() + Text(chat.serverInfo.networkStatus.statusString) + } + } + } + .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) + } +} + +struct ChatInfoView_Previews: PreviewProvider { + var chatInfo = sampleDirectChatInfo + + static var previews: some View { + ChatInfoView(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: [])) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 04b40f4995..81ec993559 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -10,8 +10,9 @@ import SwiftUI struct ChatView: View { @EnvironmentObject var chatModel: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat @State private var inProgress: Bool = false + @State private var showChatInfo = false var body: some View { VStack { @@ -33,7 +34,8 @@ struct ChatView: View { SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } - .navigationTitle(chatInfo.chatViewName) + .navigationTitle(chat.chatInfo.chatViewName) + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { chatModel.chatId = nil } label: { @@ -43,6 +45,25 @@ struct ChatView: View { } } } + ToolbarItem(placement: .principal) { + Button { + showChatInfo = true + } label: { + HStack { + ChatInfoImage(chat: chat) + .frame(width: 32, height: 32) + .padding(.trailing, 4) + VStack { + Text(chat.chatInfo.localDisplayName).font(.headline) + Text(chat.chatInfo.fullName).font(.subheadline) + } + } + .foregroundColor(.primary) + } + .sheet(isPresented: $showChatInfo) { + ChatInfoView(chat: chat) + } + } } .navigationBarBackButtonHidden(true) .onTapGesture { @@ -60,8 +81,8 @@ struct ChatView: View { func sendMessage(_ msg: String) { do { - let chatItem = try apiSendMessage(type: chatInfo.chatType, id: chatInfo.apiId, msg: .text(msg)) - chatModel.addChatItem(chatInfo, chatItem) + let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg)) + chatModel.addChatItem(chat.chatInfo, chatItem) } catch { print(error) } @@ -82,7 +103,7 @@ struct ChatView_Previews: PreviewProvider { chatItemSample(7, .directSnd, .now, "👍👍👍👍"), chatItemSample(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(chatInfo: sampleDirectChatInfo) + return ChatView(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: [])) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 7899627f55..5f82d44ae1 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -32,7 +32,7 @@ struct ChatListNavLink: View { } private func chatView() -> some View { - ChatView(chatInfo: chat.chatInfo) + ChatView(chat: chat) .onAppear { do { let cInfo = chat.chatInfo @@ -52,7 +52,7 @@ struct ChatListNavLink: View { destination: { chatView() }, label: { ChatPreviewView(chat: chat) } ) - .disabled(!contact.connected) + .disabled(!contact.ready) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { alertContact = contact diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5f4244a98c..052637d92a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -13,18 +13,21 @@ struct ChatPreviewView: View { var body: some View { let cItem = chat.chatItems.last - 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 HStack(spacing: 8) { - Image(systemName: iconName) - .resizable() - .foregroundColor(Color(uiColor: .secondarySystemBackground)) - .frame(width: 63, height: 63) - .padding(.leading, 4) + 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(.green) + .frame(width: 5, height: 5) + .padding([.bottom, .leading], 1) + } + } + .padding(.leading, 4) + VStack(spacing: 0) { HStack(alignment: .top) { Text(chat.chatInfo.chatViewName) @@ -46,7 +49,7 @@ struct ChatPreviewView: View { .padding([.leading, .trailing], 8) .padding(.bottom, 4) } - else if case let .direct(contact) = chat.chatInfo, !contact.connected { + 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) diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift new file mode 100644 index 0000000000..a50158a384 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -0,0 +1,33 @@ +// +// 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 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(uiColor: .secondarySystemBackground)) + } +} + +struct ChatInfoImage_Previews: PreviewProvider { + static var previews: some View { + ChatInfoImage(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: [])) + .previewLayout(.fixed(width: 63, height: 63)) + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 28a608d78d..5de14db513 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -35,6 +35,10 @@ 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 */; }; @@ -118,6 +122,8 @@ 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 = ""; }; @@ -194,6 +200,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 5C971E1F27AEBF7000C8A3CE /* Helpers */, 5C5F4AC227A5E9AF00B51EF1 /* Chat */, 5CB9250B27A942F300ACCCDD /* ChatList */, 5CB924DD27A8622200ACCCDD /* NewChat */, @@ -209,6 +216,7 @@ children = ( 5CE4407427ADB657007B033A /* ChatItem */, 5C2E260E27A30FDC00F70299 /* ChatView.swift */, + 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5CE4407127ADB1D0007B033A /* Emoji.swift */, @@ -247,6 +255,14 @@ path = Model; sourceTree = ""; }; + 5C971E1F27AEBF7000C8A3CE /* Helpers */ = { + isa = PBXGroup; + children = ( + 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( @@ -543,10 +559,12 @@ 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 */, @@ -578,10 +596,12 @@ 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 */, From 5aabf87898c592c633f9cd27c4a50d623d4877e8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 6 Feb 2022 07:44:41 +0000 Subject: [PATCH 62/82] ios: highlight URLs in texts (#272) * ios: highlight URLs in texts * Apply suggestions from code review --- .../MyPlayground.playground/Contents.swift | 12 +++++ .../timeline.xctimeline | 2 +- .../Views/Chat/ChatItem/TextItemView.swift | 46 ++++++++++++++++++- .../Views/ChatList/ChatPreviewView.swift | 4 +- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift index 16f46301b3..1b54844630 100644 --- a/apps/ios/Shared/MyPlayground.playground/Contents.swift +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -12,3 +12,15 @@ 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) +} diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline index a6df7c391e..522d23c91f 100644 --- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -3,7 +3,7 @@ version = "3.0"> diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift index a9a25b01f2..026fc4d745 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -11,14 +11,14 @@ import SwiftUI struct TextItemView: View { 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 = min(300, width * 0.78) - return VStack { - Text(chatItem.content.text) + messageText(chatItem.content.text, sent: sent) .padding(.top, 8) .padding(.horizontal, 12) .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .leading) @@ -42,13 +42,55 @@ struct TextItemView: View { alignment: sent ? .trailing : .leading ) } + + private func messageText(_ s: String, sent: Bool = false) -> Text { + if s == "" { return Text("") } + let parts = s.split(separator: " ") + print(parts) + var res = wordToText(parts[0], sent) + var i = 1 + while i < parts.count { + res = res + Text(" ") + wordToText(parts[i], sent) + i = i + 1 + } + return res + } + + private func wordToText(_ s: String.SubSequence, _ sent: Bool) -> Text { + switch true { + case s.starts(with: "http://") || s.starts(with: "https://"): + let str = String(s) + return Text(AttributedString(str, attributes: AttributeContainer([ + .link: NSURL(string: str) as Any, + .foregroundColor: (sent ? UIColor.white : nil) as Any + ]))).underline() + 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 mdText(_ s: String.SubSequence) -> Text { + Text(s[s.index(s.startIndex, offsetBy: 1).. Date: Sun, 6 Feb 2022 08:21:40 +0000 Subject: [PATCH 63/82] each command takes lock if it needs it (#273) --- src/Simplex/Chat.hs | 47 ++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3e4fde6f23..65c260a09d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -112,9 +112,9 @@ execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of Left e -> pure . CRChatError . ChatError $ CECommandError e Right cmd -> do - ChatController {chatLock = l, smpAgent = a, currentUser} <- ask + ChatController {currentUser} <- ask user <- readTVarIO currentUser - withAgentLock a . withLock l $ either CRChatCmdError id <$> runExceptT (processChatCommand user cmd) + either CRChatCmdError id <$> runExceptT (processChatCommand user cmd) toView :: ChatMonad m => ChatResponse -> m () toView event = do @@ -129,7 +129,7 @@ processChatCommand user@User {userId, profile} = \case CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ CRChatError ChatErrorNotImplemented APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented - APISendMessage cType chatId mc -> case cType of + APISendMessage cType chatId mc -> withChatLock $ case cType of CTDirect -> do ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId ci <- sendDirectChatItem userId ct (XMsgNew mc) (CISndMsgContent mc) @@ -148,7 +148,7 @@ processChatCommand user@User {userId, profile} = \case withStore (\st -> getContactGroupNames st userId ct) >>= \case [] -> do conns <- withStore $ \st -> getContactConnections st userId ct - procCmd $ do + withChatLock . procCmd $ do withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId ct @@ -160,11 +160,11 @@ processChatCommand user@User {userId, profile} = \case APIAcceptContact connReqId -> do UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p} <- withStore $ \st -> getContactRequest st userId connReqId - procCmd $ do + 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 -> do + APIRejectContact connReqId -> withChatLock $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \st -> getContactRequest st userId connReqId @@ -173,29 +173,29 @@ processChatCommand user@User {userId, profile} = \case pure $ CRContactRequestRejected cReq ChatHelp section -> pure $ CRChatHelp section Welcome -> pure $ CRWelcome user - AddContact -> procCmd $ do + AddContact -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) withStore $ \st -> createDirectConnection st userId connId pure $ CRInvitation cReq - Connect (Just (ACR SCMInvitation cReq)) -> procCmd $ do + Connect (Just (ACR SCMInvitation cReq)) -> withChatLock . procCmd $ do connect cReq $ XInfo profile pure CRSentConfirmation - Connect (Just (ACR SCMContact cReq)) -> procCmd $ do + Connect (Just (ACR SCMContact cReq)) -> withChatLock . procCmd $ do connect cReq $ XContact profile Nothing pure CRSentInvitation Connect Nothing -> throwChatError CEInvalidConnReq - ConnectAdmin -> procCmd $ do + ConnectAdmin -> withChatLock . procCmd $ do connect adminContactReq $ XContact profile Nothing pure CRSentInvitation DeleteContact cName -> do contactId <- withStore $ \st -> getContactIdByName st userId cName processChatCommand user $ APIDeleteChat CTDirect contactId ListContacts -> CRContactsList <$> withStore (`getUserContacts` user) - CreateMyAddress -> procCmd $ do + CreateMyAddress -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) withStore $ \st -> createUserContactLink st userId connId cReq pure $ CRUserContactLinkCreated cReq - DeleteMyAddress -> do + DeleteMyAddress -> withChatLock $ do conns <- withStore $ \st -> getUserContactLinkConnections st userId procCmd $ do withAgent $ \a -> forM_ conns $ \conn -> @@ -216,7 +216,7 @@ processChatCommand user@User {userId, profile} = \case NewGroup gProfile -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) - AddMember gName cName memRole -> do + AddMember gName cName memRole -> 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 @@ -243,7 +243,7 @@ processChatCommand user@User {userId, profile} = \case | otherwise -> throwChatError $ CEGroupDuplicateMember cName JoinGroup gName -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \st -> getGroupInvitation st user gName - procCmd $ do + withChatLock . procCmd $ do agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership g :: GroupMember) withStore $ \st -> do createMemberConnection st userId fromMember agentConnId @@ -258,14 +258,14 @@ processChatCommand user@User {userId, profile} = \case Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus} -> do let userRole = memberRole (membership :: GroupMember) when (userRole < GRAdmin || userRole < mRole) $ throwChatError CEGroupUserRole - procCmd $ do + 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 -> do Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName - procCmd $ do + withChatLock . procCmd $ do void $ sendGroupMessage members XGrpLeave mapM_ deleteMemberConnection members withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft @@ -277,7 +277,7 @@ processChatCommand user@User {userId, profile} = \case memberRole (membership :: GroupMember) == GROwner || (s == GSMemRemoved || s == GSMemLeft || s == GSMemGroupDeleted || s == GSMemInvited) unless canDelete $ throwChatError CEGroupUserRole - procCmd $ do + withChatLock . procCmd $ do when (memberActive membership) . void $ sendGroupMessage members XGrpDel mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g @@ -288,7 +288,7 @@ processChatCommand user@User {userId, profile} = \case groupId <- withStore $ \st -> getGroupIdByName st user gName let mc = MCText $ safeDecodeUtf8 msg processChatCommand user $ APISendMessage CTGroup groupId mc - SendFile cName f -> do + SendFile cName f -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) @@ -299,7 +299,7 @@ processChatCommand user@User {userId, profile} = \case withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci - SendGroupFile gName f -> do + SendGroupFile gName f -> withChatLock $ do (fileSize, chSize) <- checkSndFile f Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved @@ -322,7 +322,7 @@ processChatCommand user@User {userId, profile} = \case ReceiveFile fileId filePath_ -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName - procCmd $ do + withChatLock . procCmd $ do tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case Right agentConnId -> do filePath <- getRcvFilePath fileId filePath_ fileName @@ -333,7 +333,7 @@ processChatCommand user@User {userId, profile} = \case Left e -> throwError e CancelFile fileId -> do ft' <- withStore (\st -> getFileTransfer st userId fileId) - procCmd $ case ft' of + withChatLock . procCmd $ case ft' of FTSnd fts -> do forM_ fts $ \ft -> cancelSndFileTransfer ft pure $ CRSndGroupFileCancelled fts @@ -350,12 +350,15 @@ processChatCommand user@User {userId, profile} = \case let user' = (user :: User) {localDisplayName = displayName, profile = p} asks currentUser >>= atomically . (`writeTVar` user') contacts <- withStore (`getUserContacts` user) - procCmd $ do + withChatLock . procCmd $ do forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p pure $ CRUserProfileUpdated profile p QuitChat -> liftIO exitSuccess ShowVersion -> pure CRVersionInfo where + 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 From 408a30c25b6e066cf7e31764fe007b1f15ab1a82 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 6 Feb 2022 16:18:01 +0000 Subject: [PATCH 64/82] simplify mobile API to have single controller (#274) * simplify mobile API to have single controller * update chat response in swift * add async to stack --- apps/ios/Shared/Model/SimpleXAPI.swift | 66 +++--- .../Views/Chat/ChatItem/TextItemView.swift | 1 - package.yaml | 1 + simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 222 ++++++++++-------- src/Simplex/Chat/Controller.hs | 24 +- src/Simplex/Chat/Mobile.hs | 78 +----- src/Simplex/Chat/Terminal.hs | 18 +- src/Simplex/Chat/Terminal/Input.hs | 23 +- src/Simplex/Chat/Terminal/Output.hs | 7 +- src/Simplex/Chat/View.hs | 13 +- tests/ChatClient.hs | 18 +- tests/ChatTests.hs | 5 +- tests/MobileTests.hs | 43 ++++ tests/Test.hs | 2 + 15 files changed, 287 insertions(+), 237 deletions(-) create mode 100644 tests/MobileTests.hs diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 58d47066f3..da3dedaeb0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,13 +15,16 @@ 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 apiUpdateProfile(profile: Profile) + case updateProfile(profile: Profile) case createMyAddress case deleteMyAddress case showMyAddress @@ -32,32 +35,22 @@ enum ChatCommand { var cmdString: String { get { switch self { - 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 .apiUpdateProfile(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 + 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 } } } @@ -69,6 +62,8 @@ struct APIResponse: Decodable { 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) @@ -90,11 +85,14 @@ enum ChatResponse: Decodable, Error { case contactSubError(contact: Contact, chatError: ChatError) 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" @@ -116,6 +114,7 @@ enum ChatResponse: Decodable, Error { case .contactSubError: return "contactSubError" case .newChatItem: return "newChatItem" case .chatCmdError: return "chatCmdError" + case .chatError: return "chatError" } } } @@ -124,6 +123,8 @@ enum ChatResponse: Decodable, Error { 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 @@ -145,6 +146,7 @@ enum ChatResponse: Decodable, Error { case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" case let .newChatItem(chatItem): return String(describing: chatItem) case let .chatCmdError(chatError): return String(describing: chatError) + case let .chatError(chatError): return String(describing: chatError) } } } @@ -260,7 +262,7 @@ func apiDeleteChat(type: ChatType, id: Int64) throws { } func apiUpdateProfile(profile: Profile) throws -> Profile? { - let r = try chatSendCmd(.apiUpdateProfile(profile: profile)) + let r = try chatSendCmd(.updateProfile(profile: profile)) switch r { case .userProfileNoChange: return nil case let .userProfileUpdated(_, toProfile): return toProfile @@ -423,16 +425,18 @@ private func encodeCJSON(_ value: T) -> [CChar] { enum ChatError: Decodable { case error(errorType: ChatErrorType) - case errorMessage(errorMessage: String) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) - case errorNotImplemented } enum ChatErrorType: Decodable { - case groupUserRole + 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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift index 026fc4d745..43c0c399bc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -46,7 +46,6 @@ struct TextItemView: View { private func messageText(_ s: String, sent: Bool = false) -> Text { if s == "" { return Text("") } let parts = s.split(separator: " ") - print(parts) var res = wordToText(parts[0], sent) var i = 1 while i < parts.count { diff --git a/package.yaml b/package.yaml index b8eb26a949..a093a0cdd1 100644 --- a/package.yaml +++ b/package.yaml @@ -14,6 +14,7 @@ extra-source-files: dependencies: - aeson == 2.0.* - ansi-terminal >= 0.10 && < 0.12 + - async == 2.2.* - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 504b01c192..01568eb81f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -46,6 +46,7 @@ library build-depends: aeson ==2.0.* , ansi-terminal >=0.10 && <0.12 + , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 @@ -80,6 +81,7 @@ executable simplex-chat build-depends: aeson ==2.0.* , ansi-terminal >=0.10 && <0.12 + , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 @@ -112,6 +114,7 @@ test-suite simplex-chat-test ChatClient ChatTests MarkdownTests + MobileTests ProtocolTests Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 65c260a09d..308810edbd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -43,7 +43,7 @@ import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Types -import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM) +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 @@ -58,7 +58,7 @@ 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 @@ -83,13 +83,14 @@ defaultChatConfig = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -newChatController :: SQLiteStore -> User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController +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 currentUser <- newTVarIO user smpAgent <- getSMPAgentClient cfg {dbFile = dbFilePrefix <> "_agent.db", smpServers} + agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize @@ -97,10 +98,20 @@ newChatController chatStore user config@ChatConfig {agentConfig = cfg, tbqSize} chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty - pure ChatController {activeTo, firstTime, currentUser, smpAgent, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} -runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => m () -runChatController = race_ agentSubscriber notificationSubscriber +runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +runChatController = race_ notificationSubscriber . agentSubscriber + +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 a -> m a withLock lock = @@ -110,26 +121,31 @@ withLock lock = execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of - Left e -> pure . CRChatError . ChatError $ CECommandError e - Right cmd -> do - ChatController {currentUser} <- ask - user <- readTVarIO currentUser - either CRChatCmdError id <$> runExceptT (processChatCommand user cmd) + Left e -> pure $ chatCmdError e + Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd) toView :: ChatMonad m => ChatResponse -> m () toView event = do q <- asks outputQ atomically $ writeTBQueue q (Nothing, event) -processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResponse -processChatCommand user@User {userId, profile} = \case - APIGetChats -> CRApiChats <$> withStore (`getChatPreviews` user) - APIGetChat cType cId pagination -> case cType of +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 $ CRChatError ChatErrorNotImplemented - APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented - APISendMessage cType chatId mc -> withChatLock $ case cType of + 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) @@ -141,8 +157,8 @@ processChatCommand user@User {userId, profile} = \case ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) setActive $ ActiveG gName pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported" - APIDeleteChat cType chatId -> case cType of + 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 @@ -155,16 +171,16 @@ processChatCommand user@User {userId, profile} = \case unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs - CTGroup -> pure $ CRChatCmdError ChatErrorNotImplemented - CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported" - APIAcceptContact connReqId -> do + 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 -> withChatLock $ do + APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \st -> getContactRequest st userId connReqId @@ -172,51 +188,51 @@ processChatCommand user@User {userId, profile} = \case withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq ChatHelp section -> pure $ CRChatHelp section - Welcome -> pure $ CRWelcome user - AddContact -> withChatLock . procCmd $ do + Welcome -> withUser $ pure . CRWelcome + AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMInvitation) withStore $ \st -> createDirectConnection st userId connId pure $ CRInvitation cReq - Connect (Just (ACR SCMInvitation cReq)) -> withChatLock . procCmd $ do - connect cReq $ XInfo profile + Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do + connect userId cReq $ XInfo profile pure CRSentConfirmation - Connect (Just (ACR SCMContact cReq)) -> withChatLock . procCmd $ do - connect cReq $ XContact profile Nothing + 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 -> withChatLock . procCmd $ do - connect adminContactReq $ XContact profile Nothing + ConnectAdmin -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do + connect userId adminContactReq $ XContact profile Nothing pure CRSentInvitation - DeleteContact cName -> do + DeleteContact cName -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName - processChatCommand user $ APIDeleteChat CTDirect contactId - ListContacts -> CRContactsList <$> withStore (`getUserContacts` user) - CreateMyAddress -> withChatLock . procCmd $ do + 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 pure $ CRUserContactLinkCreated cReq - DeleteMyAddress -> withChatLock $ do + DeleteMyAddress -> withUser $ \User {userId} -> withChatLock $ do conns <- withStore $ \st -> getUserContactLinkConnections st userId procCmd $ do withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteUserContactLink st userId pure CRUserContactLinkDeleted - ShowMyAddress -> CRUserContactLink <$> withStore (`getUserContactLink` userId) - AcceptContact cName -> do + ShowMyAddress -> CRUserContactLink <$> (withUser $ \User {userId} -> withStore (`getUserContactLink` userId)) + AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName - processChatCommand user $ APIAcceptContact connReqId - RejectContact cName -> do + processChatCommand $ APIAcceptContact connReqId + RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName - processChatCommand user $ APIRejectContact connReqId - SendMessage cName msg -> do + processChatCommand $ APIRejectContact connReqId + SendMessage cName msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand user $ APISendMessage CTDirect contactId mc - NewGroup gProfile -> do + processChatCommand $ APISendMessage CTDirect contactId mc + NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) - AddMember gName cName memRole -> withChatLock $ do + 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 @@ -241,7 +257,7 @@ processChatCommand user@User {userId, profile} = \case Just cReq -> sendInvitation memberId cReq Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName - JoinGroup gName -> do + 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) @@ -251,7 +267,7 @@ processChatCommand user@User {userId, profile} = \case updateGroupMemberStatus st userId (membership g) GSMemAccepted pure $ CRUserAcceptedGroupSent g MemberRole _gName _cName _mRole -> throwChatError $ CECommandError "unsupported" - RemoveMember gName cName -> do + 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 -> throwChatError $ CEGroupMemberNotFound cName @@ -263,14 +279,14 @@ processChatCommand user@User {userId, profile} = \case deleteMemberConnection m withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved pure $ CRUserDeletedMember gInfo m - LeaveGroup gName -> do + 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 -> do + DeleteGroup gName -> withUser $ \user -> do g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroupByName st user gName let s = memberStatus membership canDelete = @@ -282,13 +298,13 @@ processChatCommand user@User {userId, profile} = \case mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g pure $ CRGroupDeletedUser gInfo - ListMembers gName -> CRGroupMembers <$> withStore (\st -> getGroupByName st user gName) - ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` user) - SendGroupMessage gName msg -> do + 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 user $ APISendMessage CTGroup groupId mc - SendFile cName f -> withChatLock $ do + processChatCommand $ APISendMessage CTGroup groupId mc + SendFile cName f -> withUser $ \User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) @@ -299,7 +315,7 @@ processChatCommand user@User {userId, profile} = \case withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci - SendGroupFile gName f -> withChatLock $ do + SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved @@ -319,7 +335,7 @@ processChatCommand user@User {userId, profile} = \case 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_ -> do + ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName withChatLock . procCmd $ do @@ -331,7 +347,7 @@ processChatCommand user@User {userId, profile} = \case Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft Left e -> throwError e - CancelFile fileId -> do + CancelFile fileId -> withUser $ \User {userId} -> do ft' <- withStore (\st -> getFileTransfer st userId fileId) withChatLock . procCmd $ case ft' of FTSnd fts -> do @@ -341,18 +357,19 @@ processChatCommand user@User {userId, profile} = \case cancelRcvFileTransfer ft pure $ CRRcvFileCancelled ft FileStatus fileId -> - CRFileTransferStatus <$> withStore (\st -> getFileTransferProgress st userId fileId) - ShowProfile -> pure $ CRUserProfile profile - UpdateProfile p@Profile {displayName} - | p == profile -> pure CRUserProfileNoChange - | otherwise -> do - withStore $ \st -> updateUserProfile st user p - let user' = (user :: User) {localDisplayName = displayName, profile = p} - asks currentUser >>= atomically . (`writeTVar` user') - contacts <- withStore (`getUserContacts` user) - withChatLock . procCmd $ do - forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p - pure $ CRUserProfileUpdated profile p + 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 -> pure CRVersionInfo where @@ -367,13 +384,13 @@ processChatCommand user@User {userId, profile} = \case -- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 -- void . forkIO $ -- withAgentLock a . withLock l $ - -- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatError)) + -- (atomically . writeTBQueue q) . (Just corrId,) =<< (action `catchError` (pure . CRChatCmdError)) -- pure $ CRCmdAccepted corrId -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse procCmd = id - connect :: ConnectionRequestUri c -> ChatMsgEvent -> m () - connect cReq msg = do + 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 contactMember :: Contact -> [GroupMember] -> Maybe GroupMember @@ -416,31 +433,30 @@ 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) => 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` (toView . CRChatError) + processAgentMessage u connId msg `catchError` (toView . CRChatError) -subscribeUserConnections :: forall m. (MonadUnliftIO m, MonadReader ChatController 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 -> (subscribe (contactConnId ct) >> toView (CRContactSubscribed ct)) `catchError` (toView . CRContactSubError ct) - subscribeGroups user = do + subscribeGroups = do groups <- withStore (`getUserGroups` user) forM_ groups $ \(Group g@GroupInfo {membership} members) -> do let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members @@ -456,7 +472,7 @@ subscribeUserConnections = void . runExceptT $ do forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) -> subscribe cId `catchError` (toView . CRMemberSubError g c) toView $ CRGroupSubscribed g - subscribeFiles user = do + subscribeFiles = do withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile where @@ -477,10 +493,10 @@ subscribeUserConnections = void . runExceptT $ do where resume RcvFileInfo {agentConnId = AgentConnId cId} = subscribe cId `catchError` (toView . CRRcvFileSubError ft) - subscribePendingConnections user = do + subscribePendingConnections = do cs <- withStore (`getPendingConnections` user) subscribeConns cs `catchError` \_ -> pure () - subscribeUserContactLink User {userId} = do + subscribeUserContactLink = do cs <- withStore (`getUserContactLinkConnections` userId) (subscribeConns cs >> toView CRUserContactLinkSubscribed) `catchError` (toView . CRUserContactLinkSubError) @@ -489,8 +505,9 @@ subscribeUserConnections = void . runExceptT $ do withAgent $ \a -> forM_ conns $ subscribeConnection a . aConnId -processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agent -> m () -processAgentMessage user@User {userId, profile} agentConnId agentMessage = +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_ @@ -1026,7 +1043,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage = 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 = AgentConnId acId} = @@ -1319,6 +1336,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 @@ -1336,7 +1365,10 @@ withStore action = chatCommandP :: Parser ChatCommand chatCommandP = - "/_get chats" $> APIGetChats + ("/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) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f3126037de..d38b5f2daf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -8,6 +8,7 @@ module Simplex.Chat.Controller where +import Control.Concurrent.Async (Async) import Control.Exception import Control.Monad.Except import Control.Monad.IO.Unlift @@ -54,10 +55,11 @@ 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, + agentAsync :: TVar (Maybe (Async ())), chatStore :: SQLiteStore, idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, @@ -78,7 +80,10 @@ instance ToJSON HelpSection where toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" data ChatCommand - = APIGetChats + = ShowActiveUser + | CreateActiveUser Profile + | StartChat + | APIGetChats | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent @@ -120,7 +125,9 @@ data ChatCommand deriving (Show) data ChatResponse - = CRApiChats {chats :: [AChat]} + = CRActiveUser {user :: User} + | CRChatStarted + | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} | CRNewChatItem {chatItem :: AChatItem} | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile @@ -198,10 +205,8 @@ instance ToJSON ChatResponse where data ChatError = ChatError {errorType :: ChatErrorType} - | ChatErrorMessage {errorMessage :: String} | ChatErrorAgent {agentError :: AgentErrorType} | ChatErrorStore {storeError :: StoreError} - | ChatErrorNotImplemented deriving (Show, Exception, Generic) instance ToJSON ChatError where @@ -209,9 +214,13 @@ instance ToJSON ChatError where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" data ChatErrorType - = CEGroupUserRole + = CENoActiveUser + | CEActiveUserExists + | CEChatNotStarted | CEInvalidConnReq + | CEInvalidChatMessage {message :: String} | CEContactGroups {contact :: Contact, groupNames :: [GroupName]} + | CEGroupUserRole | CEGroupContactRole {contactName :: ContactName} | CEGroupDuplicateMember {contactName :: ContactName} | CEGroupDuplicateMemberId @@ -240,6 +249,9 @@ instance ToJSON ChatErrorType where 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 >>= atomically . (`writeTVar` to) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 2803293638..293211a6a7 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -6,13 +6,10 @@ module Simplex.Chat.Mobile where -import Control.Concurrent (forkIO) import Control.Concurrent.STM -import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson (ToJSON (..), (.=)) +import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J -import qualified Data.Aeson.Encoding as JE import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (find) @@ -26,36 +23,16 @@ import Simplex.Chat.Store import Simplex.Chat.Types import Simplex.Messaging.Protocol (CorrId (..)) -foreign export ccall "chat_init_store" cChatInitStore :: CString -> IO (StablePtr ChatStore) - -foreign export ccall "chat_get_user" cChatGetUser :: StablePtr ChatStore -> IO CJSONString - -foreign export ccall "chat_create_user" cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString - -foreign export ccall "chat_start" cChatStart :: StablePtr ChatStore -> IO (StablePtr ChatController) +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 --- | creates or connects to chat store -cChatInitStore :: CString -> IO (StablePtr ChatStore) -cChatInitStore fp = peekCAString fp >>= chatInitStore >>= newStablePtr - --- | returns JSON in the form `{"user": }` or `{}` in case there is no active user (to show dialog to enter displayName/fullName) -cChatGetUser :: StablePtr ChatStore -> IO CJSONString -cChatGetUser cc = deRefStablePtr cc >>= chatGetUser >>= newCAString - --- | accepts Profile JSON, returns JSON `{"user": }` or `{"error": ""}` -cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString -cChatCreateUser cPtr profileCJson = do - c <- deRefStablePtr cPtr - p <- peekCAString profileCJson - newCAString =<< chatCreateUser c p - --- | this function starts chat - it cannot be started during initialization right now, as it cannot work without user (to be fixed later) -cChatStart :: StablePtr ChatStore -> IO (StablePtr ChatController) -cChatStart st = deRefStablePtr st >>= chatStart >>= newStablePtr +-- | 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 @@ -78,43 +55,15 @@ mobileChatOpts = type CJSONString = CString -data ChatStore = ChatStore - { dbFilePrefix :: FilePath, - chatStore :: SQLiteStore - } - -chatInitStore :: String -> IO ChatStore -chatInitStore dbFilePrefix = do - let f = chatStoreFile dbFilePrefix - chatStore <- createStore f $ dbPoolSize defaultChatConfig - pure ChatStore {dbFilePrefix, chatStore} - getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> getUsers st --- | returns JSON in the form `{"user": }` or `{}` -chatGetUser :: ChatStore -> IO JSONString -chatGetUser ChatStore {chatStore} = - maybe "{}" userObject <$> getActiveUser_ chatStore - --- | returns JSON in the form `{"user": }` or `{"error": ""}` -chatCreateUser :: ChatStore -> JSONString -> IO JSONString -chatCreateUser ChatStore {chatStore} profileJson = - case J.eitherDecodeStrict' $ B.pack profileJson of - Left e -> pure $ err e - Right p -> either err userObject <$> runExceptT (createUser chatStore p True) - where - err e = jsonObject $ "error" .= show e - -userObject :: User -> JSONString -userObject user = jsonObject $ "user" .= user - -chatStart :: ChatStore -> IO ChatController -chatStart ChatStore {dbFilePrefix, chatStore} = do - Just user <- getActiveUser_ chatStore - cc <- newChatController chatStore user defaultChatConfig mobileChatOpts {dbFilePrefix} . const $ pure () - void . forkIO $ runReaderT runChatController cc - pure cc +chatInit :: String -> IO ChatController +chatInit dbFilePrefix = do + let f = chatStoreFile dbFilePrefix + chatStore <- createStore f $ dbPoolSize defaultChatConfig + user_ <- getActiveUser_ chatStore + newChatController chatStore user_ defaultChatConfig mobileChatOpts {dbFilePrefix} . const $ pure () chatSendCmd :: ChatController -> String -> IO JSONString chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc @@ -124,9 +73,6 @@ chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where json (corr, resp) = LB.unpack $ J.encode APIResponse {corr, resp} -jsonObject :: J.Series -> JSONString -jsonObject = LB.unpack . JE.encodingToLazyByteString . J.pairs - data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} deriving (Generic) diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 8fd34ff325..dc08ff65bf 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,6 +1,9 @@ +{-# LANGUAGE FlexibleContexts #-} + module Simplex.Chat.Terminal where import Control.Logger.Simple +import Control.Monad.Except import Control.Monad.Reader import Simplex.Chat import Simplex.Chat.Controller @@ -11,8 +14,8 @@ import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output import Simplex.Chat.Types (User) -import Simplex.Chat.Util (whenM) import Simplex.Messaging.Util (raceAny_) +import UnliftIO (async, waitEither_) simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () simplexChat cfg opts t @@ -27,10 +30,15 @@ simplexChat cfg opts t st <- createStore f $ dbPoolSize cfg u <- getCreateActiveUser st ct <- newChatTerminal t - cc <- newChatController st u cfg opts sendNotification' + cc <- newChatController st (Just u) cfg opts sendNotification' runSimplexChat u ct cc runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () -runSimplexChat u ct = runReaderT $ do - whenM (asks firstTime) . liftIO . printToTerminal ct $ chatWelcome u - raceAny_ [runTerminalInput ct, runTerminalOutput ct, runInputLoop ct, runChatController] +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 + +runChatTerminal :: ChatTerminal -> ChatController -> IO () +runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 11e43b3253..f4bc51769e 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -25,21 +25,16 @@ getKey = Right (KeyEvent key ms) -> pure (key, ms) _ -> getKey -runInputLoop :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () -runInputLoop ct = do - q <- asks inputQ - forever $ do - s <- atomically $ readTBQueue q - r <- execChatCommand . encodeUtf8 $ T.pack s - liftIO . printToTerminal ct $ responseToView s r +runInputLoop :: ChatTerminal -> ChatController -> IO () +runInputLoop ct cc = forever $ do + s <- atomically . readTBQueue $ inputQ cc + r <- runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc + printToTerminal ct $ responseToView s r -runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () -runTerminalInput ct = do - cc <- ask - liftIO $ - withChatTerm ct $ do - updateInput ct - receiveFromTTY cc ct +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} = diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index eb911a1785..7de744f569 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -72,11 +72,10 @@ withTermLock ChatTerminal {termLock} action = do action atomically $ putTMVar termLock () -runTerminalOutput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m () -runTerminalOutput ct = do - ChatController {outputQ} <- ask +runTerminalOutput :: ChatTerminal -> ChatController -> IO () +runTerminalOutput ct cc = forever $ - atomically (readTBQueue outputQ) >>= liftIO . printToTerminal ct . responseToView "" . snd + atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView "" . snd printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 69dc24282c..48b30625ff 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -34,8 +34,10 @@ serializeChatResponse = unlines . map unStyle . responseToView "" responseToView :: String -> ChatResponse -> [StyledString] responseToView cmd = \case - CRApiChats chats -> api [sShow chats] - CRApiChat chat -> api [sShow chat] + CRActiveUser User {profile} -> r $ viewUserProfile profile + CRChatStarted -> r ["chat started"] + CRApiChats chats -> r [sShow chats] + CRApiChat chat -> r [sShow chat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> r [] @@ -115,7 +117,6 @@ responseToView cmd = \case CRMessageError prefix err -> [plain prefix <> ": " <> plain err] CRChatError e -> viewChatError e where - api = (highlight cmd :) r = (plain cmd :) -- this function should be `r` for "synchronous", `id` for "asynchronous" command responses -- r' = id @@ -447,7 +448,11 @@ fileProgress chunksNum chunkSize fileSize = 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"] @@ -488,8 +493,6 @@ viewChatError = \case 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] - ChatErrorNotImplemented -> ["chat error: not implemented"] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index a7bcf14d2f..4099568c7d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -79,7 +79,7 @@ virtualSimplexChat dbFilePrefix profile = do Right user <- runExceptT $ createUser st profile True t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t - cc <- newChatController st user cfg opts {dbFilePrefix} . const $ pure () -- no notifications + 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 @@ -108,16 +108,18 @@ 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 - concurrentlyN_ $ map ( ([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 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 6ac2ba4f3d..82916e5ff0 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -10,6 +10,7 @@ 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.Types (Profile (..), User (..)) @@ -753,7 +754,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 () @@ -811,7 +812,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 diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs new file mode 100644 index 0000000000..96d5d8c409 --- /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 + 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 From 8efb8b2f8672ae47575745850354dbc5ec2d7745 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 6 Feb 2022 18:26:22 +0000 Subject: [PATCH 65/82] use simplified chat controller, fix keyboard removing on tap (#275) --- apps/ios/Shared/ContentView.swift | 13 +--- apps/ios/Shared/Model/SimpleXAPI.swift | 62 ++++++++----------- .../Shared/SimpleX (iOS)-Bridging-Header.h | 6 +- .../Shared/SimpleX (macOS)-Bridging-Header.h | 8 +-- apps/ios/Shared/SimpleXApp.swift | 16 ++++- apps/ios/Shared/Views/Chat/ChatView.swift | 6 +- apps/ios/Shared/Views/WelcomeView.swift | 5 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 48 +++++++------- src/Simplex/Chat/Store.hs | 1 + 9 files changed, 77 insertions(+), 88 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 60e98fb4c5..0671516534 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -14,20 +14,11 @@ struct ContentView: View { if let user = chatModel.currentUser { ChatListView(user: user) .onAppear { - DispatchQueue.global().async { - while(true) { - do { - try processReceivedMsg(chatModel, chatRecvMsg()) - } catch { - print("error receiving message: ", error) - } - } - } - do { + try apiStartChat() chatModel.chats = try apiGetChats() } catch { - print(error) + fatalError("Failed to start or load chats: \(error)") } } } else { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index da3dedaeb0..b1c27c759a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -9,7 +9,6 @@ import Foundation import UIKit -private var chatStore: chat_store? private var chatController: chat_ctrl? private let jsonDecoder = getJSONDecoder() private let jsonEncoder = getJSONEncoder() @@ -186,27 +185,6 @@ enum TerminalItem: Identifiable { } } -func chatGetUser() -> User? { - let store = getStore() - print("chatGetUser") - let r: UserResponse? = decodeCJSON(chat_get_user(store)) - let user = r?.user - if user != nil { initChatCtrl(store) } - print("user", user as Any) - return user -} - -func chatCreateUser(_ p: Profile) -> User? { - let store = getStore() - print("chatCreateUser") - var str = encodeCJSON(p) - chat_create_user(store, &str) - let user = chatGetUser() - if user != nil { initChatCtrl(store) } - print("user", user as Any) - return user -} - func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! print("command", cmd.cmdString) @@ -222,6 +200,27 @@ func chatRecvMsg() throws -> ChatResponse { chatResponse(chat_recv_msg(getChatCtrl())!) } +func apiGetActiveUser() throws -> User? { + 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) } } @@ -384,23 +383,12 @@ func prettyJSON(_ obj: NSDictionary) -> String? { return nil } -private func getStore() -> chat_store { - if let store = chatStore { return store } - let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" - var cstr = dataDir.cString(using: .utf8)! - chatStore = chat_init_store(&cstr) - return chatStore! -} - -private func initChatCtrl(_ store: chat_store) { - if chatController == nil { - chatController = chat_start(store) - } -} - private func getChatCtrl() -> chat_ctrl { if let controller = chatController { return controller } - fatalError("Chat controller was not started!") + 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? { diff --git a/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h index e9f8948778..bc28b42d38 100644 --- a/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h +++ b/apps/ios/Shared/SimpleX (iOS)-Bridging-Header.h @@ -4,12 +4,8 @@ extern void hs_init(int argc, char **argv[]); -typedef void* chat_store; typedef void* chat_ctrl; -extern chat_store chat_init_store(char *path); -extern char *chat_get_user(chat_store store); -extern char *chat_create_user(chat_store store, char *data); -extern chat_ctrl chat_start(chat_store store); +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 index 62f2ba6626..bc28b42d38 100644 --- a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h +++ b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h @@ -2,14 +2,10 @@ // 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[]); +extern void hs_init(int argc, char **argv[]); -typedef void* chat_store; typedef void* chat_ctrl; -extern chat_store chat_init_store(char * path); -extern char *chat_get_user(chat_store store); -extern char *chat_create_user(chat_store store, char *data); -extern chat_ctrl chat_start(chat_store store); +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 index 222ed168da..301f8522d3 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -25,7 +25,21 @@ struct SimpleXApp: App { print(url) } .onAppear() { - chatModel.currentUser = chatGetUser() + DispatchQueue.global().async { + while(true) { + do { + try processReceivedMsg(chatModel, chatRecvMsg()) + } catch { + print("error receiving message: ", error) + } + } + } + + do { + chatModel.currentUser = try apiGetActiveUser() + } catch { + fatalError("Failed to initialize chat controller or database: \(error)") + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81ec993559..0d24aa43be 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -27,6 +27,9 @@ struct ChatView: View { .onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) } } } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } } @@ -66,9 +69,6 @@ struct ChatView: View { } } .navigationBarBackButtonHidden(true) - .onTapGesture { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } } func scrollToBottom(_ proxy: ScrollViewProxy) { diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift index 759090631c..0daf7c44db 100644 --- a/apps/ios/Shared/Views/WelcomeView.swift +++ b/apps/ios/Shared/Views/WelcomeView.swift @@ -32,8 +32,11 @@ struct WelcomeView: View { displayName: displayName, fullName: fullName ) - if let user = chatCreateUser(profile) { + do { + let user = try apiCreateActiveUser(profile) chatModel.currentUser = user + } catch { + fatalError("Failed to create user: \(error)") } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5de14db513..0cdc584e18 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -11,9 +11,6 @@ 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 */; }; - 5C116CF027ABC81C00E66D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEB27ABC81C00E66D01 /* libgmp.a */; }; - 5C116CF127ABC81C00E66D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */; }; - 5C116CF227ABC81C00E66D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C116CED27ABC81C00E66D01 /* libffi.a */; }; 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 */; }; @@ -24,6 +21,11 @@ 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 */; }; + 5C35CF6F27B031FB00FB6C6D /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6A27B031FB00FB6C6D /* libgmpxx.a */; }; + 5C35CF7027B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6B27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a */; }; + 5C35CF7127B031FB00FB6C6D /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6C27B031FB00FB6C6D /* libgmp.a */; }; + 5C35CF7227B031FB00FB6C6D /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6D27B031FB00FB6C6D /* libffi.a */; }; + 5C35CF7327B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; @@ -75,8 +77,6 @@ 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 */; }; - 5CE4406F27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */; }; - 5CE4407027AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */; }; 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 */; }; @@ -105,15 +105,17 @@ /* 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 = ""; }; - 5C116CEB27ABC81C00E66D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C116CED27ABC81C00E66D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; 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 = ""; }; + 5C35CF6A27B031FB00FB6C6D /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C35CF6B27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a"; sourceTree = ""; }; + 5C35CF6C27B031FB00FB6C6D /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C35CF6D27B031FB00FB6C6D /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a"; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; 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; }; @@ -148,8 +150,6 @@ 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 = ""; }; - 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a"; sourceTree = ""; }; - 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a"; 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 = ""; }; @@ -160,13 +160,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CE4406F27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a in Frameworks */, - 5CE4407027AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a in Frameworks */, + 5C35CF7027B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a in Frameworks */, + 5C35CF6F27B031FB00FB6C6D /* libgmpxx.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, - 5C116CF227ABC81C00E66D01 /* libffi.a in Frameworks */, - 5C116CF127ABC81C00E66D01 /* libgmpxx.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C116CF027ABC81C00E66D01 /* libgmp.a in Frameworks */, + 5C35CF7327B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a in Frameworks */, + 5C35CF7227B031FB00FB6C6D /* libffi.a in Frameworks */, + 5C35CF7127B031FB00FB6C6D /* libgmp.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -227,11 +227,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C116CED27ABC81C00E66D01 /* libffi.a */, - 5C116CEB27ABC81C00E66D01 /* libgmp.a */, - 5C116CEC27ABC81C00E66D01 /* libgmpxx.a */, - 5CE4406E27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi-ghc8.10.7.a */, - 5CE4406D27AD2648007B033A /* libHSsimplex-chat-1.1.0-1RUev578BUjGkBObUSYLQi.a */, + 5C35CF6D27B031FB00FB6C6D /* libffi.a */, + 5C35CF6C27B031FB00FB6C6D /* libgmp.a */, + 5C35CF6A27B031FB00FB6C6D /* libgmpxx.a */, + 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */, + 5C35CF6B27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a */, ); path = Libraries; sourceTree = ""; @@ -763,7 +763,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -783,7 +783,7 @@ LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -803,7 +803,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -823,7 +823,7 @@ LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 345368a689..08d05b1f3b 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2464,6 +2464,7 @@ createWithRandomBytes size gVar create = tryCreate 3 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 {contactId :: Int64} From 7883ca76575c896e9f2bc4ed336ec5d305cd11c2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 6 Feb 2022 21:06:02 +0000 Subject: [PATCH 66/82] improve text message view (#276) * show text and time on the same line * convert emails and phones to links --- apps/ios/Shared/Model/SimpleXAPI.swift | 2 + .../MyPlayground.playground/Contents.swift | 4 ++ .../timeline.xctimeline | 2 +- .../Views/Chat/ChatItem/TextItemView.swift | 58 ++++++++++++++----- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 +-- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b1c27c759a..4d6aa8e62e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -201,6 +201,8 @@ func chatRecvMsg() throws -> ChatResponse { } func apiGetActiveUser() throws -> User? { + let _ = getChatCtrl() + sleep(1) let r = try chatSendCmd(.showActiveUser) switch r { case let .activeUser(user): return user diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift index 1b54844630..991679a8b4 100644 --- a/apps/ios/Shared/MyPlayground.playground/Contents.swift +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -24,3 +24,7 @@ for match in matches { 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) diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline index 522d23c91f..ad2411a0b9 100644 --- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -3,7 +3,7 @@ version = "3.0"> diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift index 43c0c399bc..78f875d0e1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -8,6 +8,10 @@ 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}$") + struct TextItemView: View { var chatItem: ChatItem var width: CGFloat @@ -17,22 +21,25 @@ struct TextItemView: View { let sent = chatItem.chatDir.sent let minWidth = min(200, width) let maxWidth = min(300, width * 0.78) - return VStack { - messageText(chatItem.content.text, sent: sent) - .padding(.top, 8) + let meta = getDateFormatter().string(from: chatItem.meta.itemTs) + + return ZStack(alignment: .bottomTrailing) { + (messageText(chatItem.content.text, sent: sent) + reserveSpaceForMeta(meta)) + .padding(.top, 6) + .padding(.bottom, 7) .padding(.horizontal, 12) .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .leading) .foregroundColor(sent ? .white : .primary) .textSelection(.enabled) - Text(getDateFormatter().string(from: chatItem.meta.itemTs)) + Text(meta) .font(.caption) - .foregroundColor(sent ? .white : .secondary) - .padding(.bottom, 8) + .foregroundColor(sent ? Color(uiColor: .secondarySystemBackground) : .secondary) + .padding(.bottom, 4) .padding(.horizontal, 12) - .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .trailing) + .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .bottomTrailing) } .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) - .cornerRadius(10) + .cornerRadius(18) .padding(.horizontal) .frame( minWidth: 200, @@ -43,7 +50,7 @@ struct TextItemView: View { ) } - private func messageText(_ s: String, sent: Bool = false) -> Text { + private func messageText(_ s: String, sent: Bool) -> Text { if s == "" { return Text("") } let parts = s.split(separator: " ") var res = wordToText(parts[0], sent) @@ -55,14 +62,22 @@ struct TextItemView: View { 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, _ sent: Bool) -> Text { + let str = String(s) switch true { case s.starts(with: "http://") || s.starts(with: "https://"): - let str = String(s) - return Text(AttributedString(str, attributes: AttributeContainer([ - .link: NSURL(string: str) as Any, - .foregroundColor: (sent ? UIColor.white : nil) as Any - ]))).underline() + return linkText(str, prefix: "", sent: sent) + case match(str, emailRegex): + return linkText(str, prefix: "mailto:", sent: sent) + case match(str, phoneRegex): + return linkText(str, prefix: "tel:", sent: sent) default: if (s.count > 1) { switch true { @@ -78,6 +93,17 @@ struct TextItemView: View { } } + 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, sent: Bool) -> Text { + Text(AttributedString(s, attributes: AttributeContainer([ + .link: NSURL(string: prefix + s) as Any, + .foregroundColor: (sent ? UIColor.white : nil) as Any + ]))).underline() + } + private func mdText(_ s: String.SubSequence) -> Text { Text(s[s.index(s.startIndex, offsetBy: 1).. Date: Mon, 7 Feb 2022 10:36:11 +0000 Subject: [PATCH 67/82] fix loading chat, contact connection status info (#277) --- apps/ios/Shared/ContentView.swift | 10 +++ apps/ios/Shared/Model/ChatModel.swift | 16 ++++- apps/ios/Shared/Model/SimpleXAPI.swift | 1 - apps/ios/Shared/SimpleXApp.swift | 10 --- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 61 +++++++++++++++++-- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- 6 files changed, 80 insertions(+), 20 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 0671516534..a66c3e503a 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -20,6 +20,16 @@ struct ContentView: View { } catch { fatalError("Failed to start or load chats: \(error)") } + + DispatchQueue.global().async { + while(true) { + do { + try processReceivedMsg(chatModel, chatRecvMsg()) + } catch { + print("error receiving message: ", error) + } + } + } } } else { WelcomeView() diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d8709e3b50..fe539577d2 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -237,9 +237,19 @@ final class Chat: ObservableObject, Identifiable { var statusString: String { get { switch self { - case .connected: return "Connected to contact's server" - case let .error(err): return "Connecting to contact's server… (error: \(err))" - default: return "Connecting to contact's server…" + 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." } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4d6aa8e62e..46e2a434f6 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -187,7 +187,6 @@ enum TerminalItem: Identifiable { func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! - print("command", cmd.cmdString) // TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? // DispatchQueue.main.async { // termId += 1 diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 301f8522d3..35f3cad9b7 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -25,16 +25,6 @@ struct SimpleXApp: App { print(url) } .onAppear() { - DispatchQueue.global().async { - while(true) { - do { - try processReceivedMsg(chatModel, chatRecvMsg()) - } catch { - print("error receiving message: ", error) - } - } - } - do { chatModel.currentUser = try apiGetActiveUser() } catch { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index e1cee27f3a..2932478eeb 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -9,7 +9,12 @@ 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{ @@ -22,10 +27,36 @@ struct ChatInfoView: View { Text(chat.chatInfo.fullName).font(.title) .padding(.bottom) - if case .direct = chat.chatInfo { - HStack { - serverImage() - Text(chat.serverInfo.networkStatus.statusString) + 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!) + } } } } @@ -37,12 +68,32 @@ struct ChatInfoView: View { 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 { + print("Error: \(error)") + } + alertContact = nil + }, secondaryButton: .cancel() { + alertContact = nil + } + ) + } } struct ChatInfoView_Previews: PreviewProvider { var chatInfo = sampleDirectChatInfo static var previews: some View { - ChatInfoView(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: [])) + @State var showChatInfo = true + return ChatInfoView(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: []), showChatInfo: $showChatInfo) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 0d24aa43be..0bfe69d115 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -64,7 +64,7 @@ struct ChatView: View { .foregroundColor(.primary) } .sheet(isPresented: $showChatInfo) { - ChatInfoView(chat: chat) + ChatInfoView(chat: chat, showChatInfo: $showChatInfo) } } } From f5507436f3239640d61e6ff522d5fec873b3e647 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 7 Feb 2022 15:19:34 +0400 Subject: [PATCH 68/82] chat item status, CRChatItemUpdated api response (#269) --- cabal.project | 2 +- sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 46 ++-- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Messages.hs | 102 ++++++++- .../Migrations/M20220205_chat_item_status.hs | 20 ++ src/Simplex/Chat/Mobile.hs | 11 +- src/Simplex/Chat/Store.hs | 206 ++++++++++++------ src/Simplex/Chat/Terminal.hs | 5 +- src/Simplex/Chat/View.hs | 13 +- stack.yaml | 2 +- tests/ChatClient.hs | 2 +- tests/MobileTests.hs | 2 +- 14 files changed, 309 insertions(+), 107 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs diff --git a/cabal.project b/cabal.project index 2d18b117cf..d072754b5f 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: 137ff7043d49feb3b350f56783c9b64a62bc636a + tag: c9994c3a2ca945b9b67e250163cf8d560d2ed554 source-repository-package type: git diff --git a/sha256map.nix b/sha256map.nix index fc95b7a306..f673f7fabf 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "git://github.com/simplex-chat/simplexmq.git"."137ff7043d49feb3b350f56783c9b64a62bc636a" = "1jlxpmg40qkvisbf03082yrw6k2ah9dsw8pn1jqc0cyz5250qc49"; + "git://github.com/simplex-chat/simplexmq.git"."c9994c3a2ca945b9b67e250163cf8d560d2ed554" = "0lc4jb46ys0hllv5p3i3x2rw8j4s8xxmz66kp893a23ki68ljyhp"; "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 01568eb81f..457a74f60a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -26,6 +26,7 @@ library 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 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 308810edbd..e859979816 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -51,7 +51,7 @@ 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 (tryError) import System.Exit (exitFailure, exitSuccess) @@ -73,9 +73,11 @@ 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 } @@ -218,7 +220,7 @@ processChatCommand = \case deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteUserContactLink st userId pure CRUserContactLinkDeleted - ShowMyAddress -> CRUserContactLink <$> (withUser $ \User {userId} -> withStore (`getUserContactLink` userId)) + ShowMyAddress -> CRUserContactLink <$> withUser (\User {userId} -> withStore (`getUserContactLink` userId)) AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName processChatCommand $ APIAcceptContact connReqId @@ -298,7 +300,7 @@ processChatCommand = \case mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g pure $ CRGroupDeletedUser gInfo - ListMembers gName -> CRGroupMembers <$> (withUser $ \user -> withStore (\st -> getGroupByName st user gName)) + 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 @@ -312,7 +314,7 @@ processChatCommand = \case SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize ci <- sendDirectChatItem userId contact (XFile fileInv) (CISndFileInvitation fileId f) - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do @@ -546,7 +548,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> 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 @@ -558,9 +560,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 () @@ -609,8 +612,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage when (memberIsReady m) $ do 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 toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" @@ -623,7 +632,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 () @@ -821,6 +836,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 @@ -879,7 +898,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage chSize <- asks $ fileChunkSize . config 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 + 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" @@ -890,7 +909,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage chSize <- asks $ fileChunkSize . config 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 + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError let g = groupName' gInfo @@ -1248,11 +1267,11 @@ saveRcvGroupChatItem userId g m msgId MsgMeta {broker = (_, brokerTs)} ciContent ciMeta <- saveChatItem userId (CDGroupRcv g m) $ mkNewChatItem ciContent msgId brokerTs createdAt pure $ ChatItem (CIGroupRcv m) ciMeta ciContent -saveChatItem :: ChatMonad m => UserId -> ChatDirection c d -> NewChatItem d -> m CIMeta +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 tz itemTs createdAt + 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 = @@ -1262,6 +1281,7 @@ mkNewChatItem itemContent msgId itemTs createdAt = itemTs, itemContent, itemText = ciContentToText itemContent, + itemStatus = ciStatusNew, createdAt } diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d38b5f2daf..5ac7092fcc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -47,6 +47,7 @@ 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 } @@ -130,6 +131,7 @@ data ChatResponse | 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} | CRChatHelp {helpSection :: HelpSection} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index edf42d265e..f502f5d603 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -15,6 +15,7 @@ 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) @@ -30,12 +31,13 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..)) +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.Chat.Util (safeDecodeUtf8) +import Simplex.Messaging.Util ((<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest deriving (Show, Generic) @@ -73,7 +75,7 @@ jsonChatInfo = \case data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, - meta :: CIMeta, + meta :: CIMeta d, content :: CIContent d } deriving (Show, Generic) @@ -115,7 +117,7 @@ jsonCIDirection = \case CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m -data CChatItem c = forall d. CChatItem (SMsgDirection d) (ChatItem c d) +data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d) deriving instance Show (CChatItem c) @@ -123,8 +125,8 @@ 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 +chatItemId' :: ChatItem c d -> ChatItemId +chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd @@ -138,6 +140,7 @@ data NewChatItem d = NewChatItem itemTs :: ChatItemTs, itemContent :: CIContent d, itemText :: Text, + itemStatus :: CIStatus d, createdAt :: UTCTime } deriving (Show) @@ -174,21 +177,91 @@ instance ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions -data CIMeta = CIMeta +data CIMeta (d :: MsgDirection) = CIMeta { itemId :: ChatItemId, itemTs :: ChatItemTs, itemText :: Text, + itemStatus :: CIStatus d, localItemTs :: ZonedTime, createdAt :: UTCTime } - deriving (Show, Generic, FromJSON) + deriving (Show, Generic) -mkCIMeta :: ChatItemId -> Text -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta -mkCIMeta itemId itemText tz itemTs createdAt = +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, localItemTs, createdAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, localItemTs, createdAt} -instance ToJSON CIMeta where toEncoding = J.genericToEncoding J.defaultOptions +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 @@ -420,3 +493,8 @@ msgDeliveryStatusT' s = 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/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 index 293211a6a7..1904d55654 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -53,6 +53,13 @@ mobileChatOpts = logging = False } +defaultMobileConfig :: ChatConfig +defaultMobileConfig = + defaultChatConfig + { yesToMigrations = True, + agentConfig = agentConfig defaultChatConfig {yesToMigrations = True} + } + type CJSONString = CString getActiveUser_ :: SQLiteStore -> IO (Maybe User) @@ -61,9 +68,9 @@ getActiveUser_ st = find activeUser <$> getUsers st chatInit :: String -> IO ChatController chatInit dbFilePrefix = do let f = chatStoreFile dbFilePrefix - chatStore <- createStore f $ dbPoolSize defaultChatConfig + chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations defaultMobileConfig) user_ <- getActiveUser_ chatStore - newChatController chatStore user_ defaultChatConfig mobileChatOpts {dbFilePrefix} . const $ pure () + 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 diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 08d05b1f3b..b0ff6b72c3 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -13,6 +13,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store @@ -113,6 +114,8 @@ module Simplex.Chat.Store getChatPreviews, getDirectChat, getGroupChat, + getChatItemIdByAgentMsgId, + updateDirectChatItem, ) where @@ -125,6 +128,7 @@ 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) @@ -138,6 +142,7 @@ import Data.Text (Text) import qualified Data.Text as T 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) @@ -145,6 +150,7 @@ 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.Chat.Util (eitherToMaybe) @@ -160,7 +166,8 @@ import UnliftIO.STM schemaMigrations :: [(String, Query)] schemaMigrations = [ ("20220101_initial", m20220101_initial), - ("20220122_v1_1", m20220122_v1_1) + ("20220122_v1_1", m20220122_v1_1), + ("20220205_chat_item_status", m20220205_chat_item_status) ] -- | The list of migrations in ascending order by date @@ -169,7 +176,7 @@ migrations = sortBy (compare `on` name) $ map migration schemaMigrations where 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 @@ -181,7 +188,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 $ show e + | otherwise = SEInternalError $ show e insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" @@ -851,7 +858,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = Nothing -> if connType == ConnContact then pure $ RcvDirectMsgConnection c Nothing - else throwError $ SEInternal $ "connection " <> show connType <> " without entity" + else throwError $ SEInternalError $ "connection " <> show connType <> " without entity" Just entId -> case connType of ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ db entId c @@ -891,10 +898,10 @@ getConnectionEntity st User {userId, userContactId} agentConnId = toContact' contactId activeConn [(localDisplayName, displayName, fullName, viaGroup, createdAt)] = let profile = Profile {displayName, fullName} in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} - toContact' _ _ _ = Left $ SEInternal "referenced contact not found" + toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ db groupMemberId c = ExceptT $ do - firstRow (toGroupAndMember c) (SEInternal "referenced group member not found") $ + firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ DB.query db [sql| @@ -1925,8 +1932,8 @@ createMsgDeliveryEvent_ db msgDeliveryId msgDeliveryStatus createdAt = do 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 @@ -1935,10 +1942,6 @@ getMsgDeliveryId_ db connId agentMsgId = LIMIT 1 |] (connId, agentMsgId) - where - toMsgDeliveryId :: [Only Int64] -> Either StoreError Int64 - toMsgDeliveryId [Only msgDeliveryId] = Right msgDeliveryId - toMsgDeliveryId _ = Left $ SENoMsgDelivery connId agentMsgId createPendingGroupMessage :: MonadUnliftIO m => SQLiteStore -> Int64 -> MessageId -> Maybe Int64 -> m () createPendingGroupMessage st groupMemberId messageId introId_ = @@ -1975,20 +1978,20 @@ 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 => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId -createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, itemTs, itemContent, itemText, createdAt} = +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, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + 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, createdAt, createdAt) + ( (userId, contactId_, groupId_, groupMemberId_, createdByMsgId) + :. (itemSent, itemTs, itemContent, itemText, itemStatus, createdAt, createdAt) ) ciId <- insertedRowId db case createdByMsgId of @@ -2038,7 +2041,7 @@ getDirectChatPreviews_ db User {userId} = do 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, -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at + 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 @@ -2083,7 +2086,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, pu.display_name, pu.full_name, -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at, + 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, @@ -2145,20 +2148,22 @@ getDirectChat st user contactId pagination = getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatLast_ db User {userId} contactId count = do contact <- ExceptT $ getContact_ db userId contactId - chatItems <- liftIO getDirectChatItemsLast_ + chatItems <- ExceptT getDirectChatItemsLast_ pure $ Chat (DirectChat contact) (reverse chatItems) where - getDirectChatItemsLast_ :: IO [CChatItem 'CTDirect] + getDirectChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsLast_ = do tz <- getCurrentTimeZone - map (toDirectChatItem tz) + mapM (toDirectChatItem tz) <$> DB.query db [sql| - SELECT chat_item_id, item_ts, item_content, item_text, created_at - FROM chat_items - WHERE user_id = ? AND contact_id = ? - ORDER BY chat_item_id DESC + 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) @@ -2166,20 +2171,22 @@ getDirectChatLast_ db User {userId} contactId count = do 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 - chatItems <- liftIO getDirectChatItemsAfter_ + chatItems <- ExceptT getDirectChatItemsAfter_ pure $ Chat (DirectChat contact) chatItems where - getDirectChatItemsAfter_ :: IO [CChatItem 'CTDirect] + getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do tz <- getCurrentTimeZone - map (toDirectChatItem tz) + mapM (toDirectChatItem tz) <$> DB.query db [sql| - SELECT chat_item_id, item_ts, item_content, item_text, created_at - FROM chat_items - WHERE user_id = ? AND contact_id = ? AND chat_item_id > ? - ORDER BY chat_item_id ASC + 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) @@ -2187,20 +2194,22 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do 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 - chatItems <- liftIO getDirectChatItemsBefore_ + chatItems <- ExceptT getDirectChatItemsBefore_ pure $ Chat (DirectChat contact) (reverse chatItems) where - getDirectChatItemsBefore_ :: IO [CChatItem 'CTDirect] + getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do tz <- getCurrentTimeZone - map (toDirectChatItem tz) + mapM (toDirectChatItem tz) <$> DB.query db [sql| - SELECT chat_item_id, item_ts, item_content, item_text, created_at - FROM chat_items - WHERE user_id = ? AND contact_id = ? AND chat_item_id < ? - ORDER BY chat_item_id DESC + 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) @@ -2266,7 +2275,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at, + 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, @@ -2295,7 +2304,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at, + 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, @@ -2324,7 +2333,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.created_at, + 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, @@ -2373,20 +2382,75 @@ 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) -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, UTCTime) +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) -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe UTCTime) +updateDirectChatItem :: (StoreMonad m, MsgDirectionI d) => SQLiteStore -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) +updateDirectChatItem st itemId itemStatus = + liftIOEither . withTransaction st $ \db -> do + ci <- getDirectChatItem_ db itemId + DB.execute db "UPDATE chat_items SET item_status = ? WHERE chat_item_id = ?" (itemStatus, itemId) + pure ci -toDirectChatItem :: TimeZone -> ChatItemRow -> CChatItem 'CTDirect -toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt) = - let ciMeta = mkCIMeta itemId itemText tz itemTs createdAt - in case itemContent of - ACIContent d@SMDSnd ciContent -> CChatItem d $ ChatItem CIDirectSnd ciMeta ciContent - ACIContent d@SMDRcv ciContent -> CChatItem d $ ChatItem CIDirectRcv ciMeta ciContent +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 + +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 createdAt) = - [toDirectChatItem tz (itemId, itemTs, itemContent, itemText, createdAt)] +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 @@ -2394,17 +2458,24 @@ type GroupChatItemRow = ChatItemRow :. MaybeGroupMemberRow type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow toGroupChatItem :: TimeZone -> Int64 -> GroupChatItemRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, createdAt) :. memberRow_) = - let ciMeta = mkCIMeta itemId itemText tz itemTs createdAt - member_ = toMaybeGroupMember userContactId memberRow_ - in case (itemContent, member_) of - (ACIContent d@SMDSnd ciContent, Nothing) -> Right $ CChatItem d (ChatItem CIGroupSnd ciMeta ciContent) - (ACIContent d@SMDRcv ciContent, Just member) -> Right $ CChatItem d (ChatItem (CIGroupRcv member) ciMeta ciContent) - _ -> Left $ SEBadChatItem itemId +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 createdAt) :. memberRow_) = - either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, createdAt) :. memberRow_) +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. @@ -2459,7 +2530,7 @@ 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 $ show e + | otherwise -> pure . Left . SEInternalError $ show e randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) @@ -2488,9 +2559,10 @@ data StoreError | SEConnectionNotFound {agentConnId :: AgentConnId} | SEIntroNotFound | SEUniqueID - | SEInternal {message :: String} + | SEInternalError {message :: String} | SENoMsgDelivery {connId :: Int64, agentMsgId :: AgentMsgId} - | SEBadChatItem {itemId :: Int64} + | SEBadChatItem {itemId :: ChatItemId} + | SEChatItemNotFound {itemId :: ChatItemId} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index dc08ff65bf..d8e14b3422 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE NamedFieldPuns #-} module Simplex.Chat.Terminal where @@ -18,7 +19,7 @@ import Simplex.Messaging.Util (raceAny_) import UnliftIO (async, waitEither_) simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChat cfg opts t +simplexChat cfg@ChatConfig {dbPoolSize, yesToMigrations} opts t | logging opts = do setLogLevel LogInfo -- LogError withGlobalLogging logCfg initRun @@ -27,7 +28,7 @@ simplexChat cfg opts t initRun = do sendNotification' <- initializeNotifications let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f $ dbPoolSize cfg + st <- createStore f dbPoolSize yesToMigrations u <- getCreateActiveUser st ct <- newChatTerminal t cc <- newChatController st (Just u) cfg opts sendNotification' diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 48b30625ff..00141c345c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -39,6 +39,7 @@ responseToView cmd = \case CRApiChats chats -> r [sShow chats] CRApiChat chat -> r [sShow chat] CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item + CRChatItemUpdated _ -> [] CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> r [] CRChatHelp section -> case section of @@ -308,10 +309,10 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> CIMeta -> MsgContent -> [StyledString] +viewReceivedMessage :: StyledString -> CIMeta d -> MsgContent -> [StyledString] viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) -receivedWithTime_ :: StyledString -> CIMeta -> [StyledString] -> [StyledString] +receivedWithTime_ :: StyledString -> CIMeta d -> [StyledString] -> [StyledString] receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk where @@ -326,13 +327,13 @@ receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do else "%H:%M" in styleTime $ formatTime defaultTimeLocale format localTime -viewSentMessage :: StyledString -> MsgContent -> CIMeta -> [StyledString] +viewSentMessage :: StyledString -> MsgContent -> CIMeta d -> [StyledString] viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent -viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta -> [StyledString] +viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString] viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath -sentWithTime_ :: [StyledString] -> CIMeta -> [StyledString] +sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString] sentWithTime_ styledMsg CIMeta {localItemTs} = prependFirst (ttyMsgTime localItemTs <> " ") styledMsg @@ -371,7 +372,7 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -viewReceivedFileInvitation :: StyledString -> CIMeta -> RcvFileTransfer -> [StyledString] +viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString] viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] diff --git a/stack.yaml b/stack.yaml index 9c282b18e0..40df8cd5bc 100644 --- a/stack.yaml +++ b/stack.yaml @@ -48,7 +48,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 137ff7043d49feb3b350f56783c9b64a62bc636a + commit: c9994c3a2ca945b9b67e250163cf8d560d2ed554 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 4099568c7d..fa0556560b 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -75,7 +75,7 @@ cfg = virtualSimplexChat :: FilePath -> Profile -> IO TestCC virtualSimplexChat dbFilePrefix profile = do - st <- createStore (dbFilePrefix <> "_chat.db") 1 + st <- createStore (dbFilePrefix <> "_chat.db") 1 False Right user <- runExceptT $ createUser st profile True t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 96d5d8c409..f48cf71312 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -35,7 +35,7 @@ testChatApiNoUser = withTmpFiles $ do testChatApi :: IO () testChatApi = withTmpFiles $ do let f = chatStoreFile testDBPrefix - st <- createStore f 1 + st <- createStore f 1 True Right _ <- runExceptT $ createUser st aliceProfile True cc <- chatInit testDBPrefix chatSendCmd cc "/u" `shouldReturn` activeUser From d11d66fa90a9c77702123c69fe8721100c57066b Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 7 Feb 2022 18:34:54 +0400 Subject: [PATCH 69/82] connection precedence logic in getDirectChatPreviews_; update item status in object (#279) --- .../Views/UserSettings/UserAddress.swift | 2 +- src/Simplex/Chat/Store.hs | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index 3764fb4587..19ebf6c778 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -15,7 +15,7 @@ struct UserAddress: View { var body: some View { VStack (alignment: .leading) { - Text("Your 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.") + 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) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index b0ff6b72c3..0a1e01b991 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2054,12 +2054,16 @@ getDirectChatPreviews_ db User {userId} = do LEFT JOIN chat_items ci ON ci.contact_id = CIMaxDates.contact_id AND ci.item_ts = CIMaxDates.MaxDate WHERE ct.user_id = ? - AND c.connection_id IN ( - SELECT cc.connection_id - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id AND (cc.conn_status = ? OR cc.conn_status = ?) - ORDER BY cc.connection_id DESC - LIMIT 1 + 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 |] @@ -2402,10 +2406,10 @@ getChatItemIdByAgentMsgId st connId msgId = updateDirectChatItem :: (StoreMonad m, MsgDirectionI d) => SQLiteStore -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) updateDirectChatItem st itemId itemStatus = - liftIOEither . withTransaction st $ \db -> do - ci <- getDirectChatItem_ db itemId - DB.execute db "UPDATE chat_items SET item_status = ? WHERE chat_item_id = ?" (itemStatus, itemId) - pure ci + liftIOEither . withTransaction st $ \db -> runExceptT $ do + ci <- ExceptT $ getDirectChatItem_ db itemId + liftIO $ DB.execute db "UPDATE chat_items SET item_status = ? WHERE chat_item_id = ?" (itemStatus, 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 From 82d02e923ad350ca0580cbfa8b4a2e791e3f3362 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Tue, 8 Feb 2022 11:20:41 +0400 Subject: [PATCH 70/82] ios: add CIStatus type (#280) --- apps/ios/Shared/Model/ChatModel.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index fe539577d2..bdd62accdf 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -409,6 +409,15 @@ struct CIMeta: Decodable { var createdAt: Date } +enum CIStatus: Decodable { + case sndNew + case sndSent + case sndErrorAuth + case sndError(agentErrorType: AgentErrorType) + case rcvNew + case rcvRead +} + func ciMetaSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta { CIMeta( itemId: id, From 855881094b33899cfc1c8ee66e0ddc8fac0f48a0 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Tue, 8 Feb 2022 13:04:17 +0400 Subject: [PATCH 71/82] add CRContactConnecting api response (#281) --- src/Simplex/Chat.hs | 5 +++-- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Store.hs | 12 ++++++------ src/Simplex/Chat/View.hs | 1 + 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e859979816..47281fc066 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -964,8 +964,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage 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 () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5ac7092fcc..d6c6eb6f62 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -173,6 +173,7 @@ data ChatResponse | CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer} | CRSndGroupFileCancelled {sndFileTransfers :: [SndFileTransfer]} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} + | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact} | CRContactAnotherClient {contact :: Contact} | CRContactDisconnected {contact :: Contact} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 0a1e01b991..bac8603961 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -272,12 +272,12 @@ createConnection_ db userId connType entityId acId viaContact connLevel currentT where 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 -> do - currentTs <- getCurrentTime - createContact_ db userId connId profile Nothing currentTs +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 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64)) createContact_ db userId connId Profile {displayName, fullName} viaGroup currentTs = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 00141c345c..b798861716 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -89,6 +89,7 @@ responseToView cmd = \case 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)"] From b3a4c21c4b8ad61be045590b5f8961cbc1f1692e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 8 Feb 2022 09:19:25 +0000 Subject: [PATCH 72/82] updated text items (#278) * updated text items * update version * fix JSON parsing in CIDirection, refactor data samples * show group member in received messages and chat preview * use profile displayName instead of localDisplayName, do not show fullName if it is the same as displayName --- .../AccentColor.colorset/Contents.json | 9 + apps/ios/Shared/Model/ChatModel.swift | 165 +++++++++++------- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 4 +- .../Views/Chat/ChatItem/EmojiItemView.swift | 4 +- .../Views/Chat/ChatItem/TextItemView.swift | 84 +++++---- apps/ios/Shared/Views/Chat/ChatItemView.swift | 10 +- apps/ios/Shared/Views/Chat/ChatView.swift | 46 +++-- .../Views/ChatList/ChatListNavLink.swift | 10 +- .../Shared/Views/ChatList/ChatListView.swift | 12 +- .../Views/ChatList/ChatPreviewView.swift | 20 ++- .../Views/ChatList/ContactRequestView.swift | 2 +- .../Shared/Views/Helpers/ChatInfoImage.swift | 10 +- .../Views/UserSettings/SettingsView.swift | 2 +- .../Views/UserSettings/UserProfile.swift | 2 +- apps/ios/SimpleX--iOS--Info.plist | 8 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 +- 16 files changed, 245 insertions(+), 151 deletions(-) diff --git a/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json index eebacd7431..481421680d 100644 --- a/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/apps/ios/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533", + "red" : "0.000" + } + }, "idiom" : "universal" } ], diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index bdd62accdf..34e5fa5bb8 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -115,15 +115,15 @@ struct User: Decodable { // self.profile = profile // self.activeUser = activeUser // } -} -let sampleUser = User( - userId: 1, - userContactId: 1, - localDisplayName: "alice", - profile: sampleProfile, - activeUser: true -) + static let sampleData = User( + userId: 1, + userContactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeUser: true + ) +} typealias ContactName = String @@ -132,12 +132,12 @@ typealias GroupName = String struct Profile: Codable { var displayName: String var fullName: String -} -let sampleProfile = Profile( - displayName: "alice", - fullName: "Alice" -) + static let sampleData = Profile( + displayName: "alice", + fullName: "Alice" + ) +} enum ChatType: String { case direct = "@" @@ -160,6 +160,16 @@ enum ChatInfo: Identifiable, Decodable { } } + var displayName: String { + get { + switch self { + case let .direct(contact): return contact.profile.displayName + case let .group(groupInfo): return groupInfo.groupProfile.displayName + case let .contactRequest(contactRequest): return contactRequest.profile.displayName + } + } + } + var fullName: String { get { switch self { @@ -171,7 +181,7 @@ enum ChatInfo: Identifiable, Decodable { } var chatViewName: String { - get { localDisplayName + (fullName == "" || fullName == localDisplayName ? "" : " / \(fullName)") } + get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } } var id: String { @@ -211,14 +221,20 @@ enum ChatInfo: Identifiable, Decodable { 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) + ) } -let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact) - -let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo) - -let sampleContactRequestChatInfo = ChatInfo.contactRequest(contactRequest: sampleContactRequest) - final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @@ -297,21 +313,21 @@ struct Contact: Identifiable, Decodable { var id: String { get { "@\(contactId)" } } var apiId: Int64 { get { contactId } } var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } -} -let sampleContact = Contact( - contactId: 1, - localDisplayName: "alice", - profile: sampleProfile, - activeConn: sampleConnection, - createdAt: .now -) + static let sampleData = Contact( + contactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeConn: Connection.sampleData, + createdAt: .now + ) +} struct Connection: Decodable { var connStatus: String -} -let sampleConnection = Connection(connStatus: "ready") + static let sampleData = Connection(connStatus: "ready") +} struct UserContactRequest: Decodable { var contactRequestId: Int64 @@ -322,14 +338,14 @@ struct UserContactRequest: Decodable { var id: String { get { "<@\(contactRequestId)" } } var apiId: Int64 { get { contactRequestId } } -} -let sampleContactRequest = UserContactRequest( - contactRequestId: 1, - localDisplayName: "alice", - profile: sampleProfile, - createdAt: .now -) + static let sampleData = UserContactRequest( + contactRequestId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + createdAt: .now + ) +} struct GroupInfo: Identifiable, Decodable { var groupId: Int64 @@ -340,27 +356,44 @@ struct GroupInfo: Identifiable, Decodable { var id: String { get { "#\(groupId)" } } var apiId: Int64 { get { groupId } } -} -let sampleGroupInfo = GroupInfo( - groupId: 1, - localDisplayName: "team", - groupProfile: sampleGroupProfile, - createdAt: .now -) + static let sampleData = GroupInfo( + groupId: 1, + localDisplayName: "team", + groupProfile: GroupProfile.sampleData, + createdAt: .now + ) +} struct GroupProfile: Codable { var displayName: String var fullName: String + + static let sampleData = GroupProfile( + displayName: "team", + fullName: "My Team" + ) } -let sampleGroupProfile = 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 { @@ -374,21 +407,21 @@ struct ChatItem: Identifiable, Decodable { var content: CIContent var id: Int64 { get { meta.itemId } } -} -func chatItemSample(_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem { - ChatItem( - chatDir: dir, - meta: ciMetaSample(id, ts, text), - content: .sndMsgContent(msgContent: .text(text)) - ) + 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) + case groupRcv(groupMember: GroupMember) var sent: Bool { get { @@ -407,6 +440,15 @@ struct CIMeta: Decodable { 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 { @@ -418,15 +460,6 @@ enum CIStatus: Decodable { case rcvRead } -func ciMetaSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta { - CIMeta( - itemId: id, - itemTs: ts, - itemText: text, - createdAt: ts - ) -} - enum CIContent: Decodable { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 2932478eeb..6d59b19462 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -90,10 +90,8 @@ struct ChatInfoView: View { } struct ChatInfoView_Previews: PreviewProvider { - var chatInfo = sampleDirectChatInfo - static var previews: some View { @State var showChatInfo = true - return ChatInfoView(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: []), showChatInfo: $showChatInfo) + 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 index b474feee13..2ee4a93e24 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -35,8 +35,8 @@ struct EmojiItemView: View { struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ - EmojiItemView(chatItem: chatItemSample(1, .directSnd, .now, "🙂")) - EmojiItemView(chatItem: chatItemSample(2, .directRcv, .now, "👍")) + 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 index 78f875d0e1..0a396b16fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -12,54 +12,73 @@ private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=? 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 = min(300, width * 0.78) +// let minWidth = min(200, width) + let maxWidth = width * 0.78 let meta = getDateFormatter().string(from: chatItem.meta.itemTs) return ZStack(alignment: .bottomTrailing) { - (messageText(chatItem.content.text, sent: sent) + reserveSpaceForMeta(meta)) + (messageText(chatItem) + reserveSpaceForMeta(meta)) .padding(.top, 6) .padding(.bottom, 7) .padding(.horizontal, 12) - .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .leading) - .foregroundColor(sent ? .white : .primary) + .frame(minWidth: 0, alignment: .leading) +// .foregroundColor(sent ? .white : .primary) .textSelection(.enabled) + Text(meta) .font(.caption) - .foregroundColor(sent ? Color(uiColor: .secondarySystemBackground) : .secondary) + .foregroundColor(.secondary) +// .foregroundColor(sent ? Color(uiColor: .secondarySystemBackground) : .secondary) .padding(.bottom, 4) .padding(.horizontal, 12) - .frame(minWidth: minWidth, maxWidth: maxWidth, alignment: .bottomTrailing) } - .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) +// .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) + .background( + sent + ? (colorScheme == .light ? sentColorLigth : sentColorDark) + : Color(uiColor: .tertiarySystemGroupedBackground) + ) .cornerRadius(18) .padding(.horizontal) .frame( - minWidth: 200, - maxWidth: .infinity, - minHeight: 0, + maxWidth: maxWidth, maxHeight: .infinity, alignment: sent ? .trailing : .leading ) } - private func messageText(_ s: String, sent: Bool) -> Text { - if s == "" { return Text("") } - let parts = s.split(separator: " ") - var res = wordToText(parts[0], sent) - var i = 1 - while i < parts.count { - res = res + Text(" ") + wordToText(parts[i], sent) - i = i + 1 + 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 } - return res } private func reserveSpaceForMeta(_ meta: String) -> Text { @@ -69,15 +88,15 @@ struct TextItemView: View { ]))) } - private func wordToText(_ s: String.SubSequence, _ sent: Bool) -> Text { + 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: "", sent: sent) + return linkText(str, prefix: "") case match(str, emailRegex): - return linkText(str, prefix: "mailto:", sent: sent) + return linkText(str, prefix: "mailto:") case match(str, phoneRegex): - return linkText(str, prefix: "tel:", sent: sent) + return linkText(str, prefix: "tel:") default: if (s.count > 1) { switch true { @@ -97,10 +116,10 @@ struct TextItemView: View { regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil } - private func linkText(_ s: String, prefix: String, sent: Bool) -> Text { + private func linkText(_ s: String, prefix: String) -> Text { Text(AttributedString(s, attributes: AttributeContainer([ .link: NSURL(string: prefix + s) as Any, - .foregroundColor: (sent ? UIColor.white : nil) as Any + .foregroundColor: linkColor as Any ]))).underline() } @@ -112,12 +131,13 @@ struct TextItemView: View { struct TextItemView_Previews: PreviewProvider { static var previews: some View { Group{ - TextItemView(chatItem: chatItemSample(1, .directSnd, .now, "hello"), width: 360) - TextItemView(chatItem: chatItemSample(2, .directSnd, .now, "https://simplex.chat"), width: 360) - TextItemView(chatItem: chatItemSample(2, .directRcv, .now, "hello there too!!! this covers -"), width: 360) - TextItemView(chatItem: chatItemSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), width: 360) - TextItemView(chatItem: chatItemSample(2, .directRcv, .now, "https://simplex.chat"), width: 360) - TextItemView(chatItem: chatItemSample(2, .directRcv, .now, "chaT@simplex.chat"), width: 360) + TextItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360) + TextItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello"), width: 360) + TextItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat"), width: 360) + TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), width: 360) + TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), width: 360) + TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), width: 360) + TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), width: 360) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 01159d7fda..a2189b5979 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -34,11 +34,11 @@ func getDateFormatter() -> DateFormatter { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chatItem: chatItemSample(1, .directSnd, .now, "hello"), width: 360) - ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "hello there too"), width: 360) - ChatItemView(chatItem: chatItemSample(1, .directSnd, .now, "🙂"), width: 360) - ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), width: 360) - ChatItemView(chatItem: chatItemSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), width: 360) + 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 index 0bfe69d115..864fa39a74 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -8,20 +8,27 @@ 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 @State private var showChatInfo = false var body: some View { - VStack { + 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) } @@ -37,7 +44,7 @@ struct ChatView: View { SendMessageView(sendMessage: sendMessage, inProgress: inProgress) } - .navigationTitle(chat.chatInfo.chatViewName) + .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -53,12 +60,19 @@ struct ChatView: View { showChatInfo = true } label: { HStack { - ChatInfoImage(chat: chat) - .frame(width: 32, height: 32) - .padding(.trailing, 4) + ChatInfoImage( + chat: chat, + color: colorScheme == .dark + ? chatImageColorDark + : chatImageColorLight + ) + .frame(width: 32, height: 32) + .padding(.trailing, 4) VStack { - Text(chat.chatInfo.localDisplayName).font(.headline) - Text(chat.chatInfo.fullName).font(.subheadline) + Text(cInfo.displayName).font(.headline) + if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName { + Text(cInfo.fullName).font(.subheadline) + } } } .foregroundColor(.primary) @@ -94,16 +108,16 @@ struct ChatView_Previews: PreviewProvider { let chatModel = ChatModel() chatModel.chatId = "@1" chatModel.chatItems = [ - chatItemSample(1, .directSnd, .now, "hello"), - chatItemSample(2, .directRcv, .now, "hi"), - chatItemSample(3, .directRcv, .now, "hi there"), - chatItemSample(4, .directRcv, .now, "hello again"), - chatItemSample(5, .directSnd, .now, "hi there!!!"), - chatItemSample(6, .directSnd, .now, "how are you?"), - chatItemSample(7, .directSnd, .now, "👍👍👍👍"), - chatItemSample(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.") + 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: sampleDirectChatInfo, chatItems: [])) + return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 5f82d44ae1..b26ed3f303 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -175,15 +175,15 @@ struct ChatListNavLink_Previews: PreviewProvider { @State var chatId: String? = "@1" return Group { ChatListNavLink(chat: Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, .now, "hello")] + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] )) ChatListNavLink(chat: Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, .now, "hello")] + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] )) ChatListNavLink(chat: Chat( - chatInfo: sampleContactRequestChatInfo, + chatInfo: ChatInfo.sampleData.contactRequest, chatItems: [] )) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 5e24ae3b3a..ba6082e9cb 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -91,20 +91,20 @@ struct ChatListView_Previews: PreviewProvider { let chatModel = ChatModel() chatModel.chats = [ Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, .now, "hello")] + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] ), Chat( - chatInfo: sampleGroupChatInfo, - chatItems: [chatItemSample(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.")] + 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: sampleContactRequestChatInfo, + chatInfo: ChatInfo.sampleData.contactRequest, chatItems: [] ) ] - return ChatListView(user: sampleUser) + return ChatListView(user: User.sampleData) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 8905c99b78..012f57791f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -46,7 +46,7 @@ struct ChatPreviewView: View { .padding(.horizontal, 8) if let cItem = cItem { - Text(cItem.content.text) + Text(chatItemText(cItem)) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding([.leading, .trailing], 8) .padding(.bottom, 4) @@ -60,22 +60,30 @@ struct ChatPreviewView: View { } } } + + 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: sampleDirectChatInfo, + chatInfo: ChatInfo.sampleData.direct, chatItems: [] )) ChatPreviewView(chat: Chat( - chatInfo: sampleDirectChatInfo, - chatItems: [chatItemSample(1, .directSnd, .now, "hello")] + chatInfo: ChatInfo.sampleData.direct, + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] )) ChatPreviewView(chat: Chat( - chatInfo: sampleGroupChatInfo, - chatItems: [chatItemSample(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.")] + 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 index 8447a253f3..2acd47c707 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -47,7 +47,7 @@ struct ContactRequestView: View { struct ContactRequestView_Previews: PreviewProvider { static var previews: some View { - ContactRequestView(contactRequest: sampleContactRequest) + 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 index a50158a384..6f95a9be97 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -10,6 +10,7 @@ import SwiftUI struct ChatInfoImage: View { @ObservedObject var chat: Chat + var color = Color(uiColor: .tertiarySystemGroupedBackground) var body: some View { var iconName: String @@ -21,13 +22,16 @@ struct ChatInfoImage: View { return Image(systemName: iconName) .resizable() - .foregroundColor(Color(uiColor: .secondarySystemBackground)) + .foregroundColor(color) } } struct ChatInfoImage_Previews: PreviewProvider { static var previews: some View { - ChatInfoImage(chat: Chat(chatInfo: sampleDirectChatInfo, chatItems: [])) - .previewLayout(.fixed(width: 63, height: 63)) + 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/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a9b1ed3bdd..9d901956e3 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -64,7 +64,7 @@ struct SettingsView: View { struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() - chatModel.currentUser = sampleUser + chatModel.currentUser = User.sampleData return SettingsView() .environmentObject(chatModel) } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 6487e25200..ac252ed838 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -77,7 +77,7 @@ struct UserProfile: View { struct UserProfile_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() - chatModel.currentUser = sampleUser + chatModel.currentUser = User.sampleData return UserProfile() .environmentObject(chatModel) } diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 08407cc1cf..b8279472cb 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + chat.simplex.app.receive + CFBundleURLTypes @@ -15,5 +19,9 @@ + UIBackgroundModes + + fetch + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 05920d4a65..ffd38c3569 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -763,7 +763,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -783,7 +783,7 @@ LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -803,7 +803,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -823,7 +823,7 @@ LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From b06838b6512d68ab066581acdf55cab81cf03445 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Tue, 8 Feb 2022 17:27:43 +0400 Subject: [PATCH 73/82] add APIChatRead chat command (#282) --- src/Simplex/Chat.hs | 5 +++++ src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Store.hs | 29 ++++++++++++++++++++++++++++- src/Simplex/Chat/View.hs | 1 + 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 47281fc066..32251338f9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -160,6 +160,10 @@ processChatCommand = \case 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 @@ -1393,6 +1397,7 @@ chatCommandP = <|> "/_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) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d6c6eb6f62..516ff68a2a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -88,6 +88,7 @@ data ChatCommand | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent + | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) | APIDeleteChat ChatType Int64 | APIAcceptContact Int64 | APIRejectContact Int64 @@ -134,6 +135,7 @@ data ChatResponse | 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} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index bac8603961..8030f1bd03 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -116,6 +116,8 @@ module Simplex.Chat.Store getGroupChat, getChatItemIdByAgentMsgId, updateDirectChatItem, + updateDirectChatItemsRead, + updateGroupChatItemsRead, ) where @@ -2408,7 +2410,8 @@ updateDirectChatItem :: (StoreMonad m, MsgDirectionI d) => SQLiteStore -> ChatIt updateDirectChatItem st itemId itemStatus = liftIOEither . withTransaction st $ \db -> runExceptT $ do ci <- ExceptT $ getDirectChatItem_ db itemId - liftIO $ DB.execute db "UPDATE chat_items SET item_status = ? WHERE chat_item_id = ?" (itemStatus, 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)) @@ -2433,6 +2436,30 @@ getDirectChatItem_ db itemId = do 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_sent = ? + |] + (CISRcvRead, currentTs, contactId, fromItemId, toItemId, SMDRcv) + +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_sent = ? + |] + (CISRcvRead, currentTs, groupId, fromItemId, toItemId, SMDRcv) + type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, UTCTime) type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe UTCTime) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b798861716..0f0a0476dc 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -42,6 +42,7 @@ responseToView cmd = \case CRChatItemUpdated _ -> [] CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> r [] + CRCmdOk -> r ["ok"] CRChatHelp section -> case section of HSMain -> r chatHelpInfo HSFiles -> r filesHelpInfo From 7af4cdffee2ecbd58096d041b5623eb7afad0286 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Tue, 8 Feb 2022 20:38:57 +0400 Subject: [PATCH 74/82] add unreadCount and minUnreadItemId stats to Chat type (#283) --- src/Simplex/Chat/Messages.hs | 16 ++++- src/Simplex/Chat/Store.hs | 116 +++++++++++++++++++++++++++-------- 2 files changed, 105 insertions(+), 27 deletions(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index f502f5d603..1634c11b12 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -146,7 +146,11 @@ data NewChatItem d = NewChatItem deriving (Show) -- | type to show one chat with messages -data Chat c = Chat {chatInfo :: ChatInfo c, chatItems :: [CChatItem c]} +data Chat c = Chat + { chatInfo :: ChatInfo c, + chatItems :: [CChatItem c], + chatStats :: ChatStats + } deriving (Show, Generic) instance ToJSON (Chat c) where @@ -161,6 +165,16 @@ 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) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 8030f1bd03..bc094ab4e5 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2021,10 +2021,10 @@ getChatPreviews st 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 + 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 @@ -2042,19 +2042,27 @@ getDirectChatPreviews_ db User {userId} = do -- 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(item_ts) MaxDate + SELECT contact_id, MAX(item_ts) AS MaxDate FROM chat_items WHERE item_deleted != 1 GROUP BY contact_id - ) CIMaxDates ON CIMaxDates.contact_id = c.contact_id + ) CIMaxDates ON CIMaxDates.contact_id = ct.contact_id LEFT JOIN chat_items ci ON ci.contact_id = CIMaxDates.contact_id AND ci.item_ts = CIMaxDates.MaxDate + 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 ( @@ -2069,13 +2077,14 @@ getDirectChatPreviews_ db User {userId} = do ) ORDER BY ci.item_ts DESC |] - (userId, ConnReady, ConnSndReady) + (CISRcvNew, userId, ConnReady, ConnSndReady) where - toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. MaybeChatItemRow -> AChat - toDirectChatPreview tz (contactRow :. connRow :. ciRow_) = + toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow -> AChat + toDirectChatPreview tz (contactRow :. connRow :. statsRow :. ciRow_) = let contact = toContact $ contactRow :. connRow ci_ = toDirectChatItemList tz ciRow_ - in AChat SCTDirect $ Chat (DirectChat contact) ci_ + stats = toChatStats statsRow + in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do @@ -2091,6 +2100,8 @@ getGroupChatPreviews_ db User {userId, userContactId} = do 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 @@ -2102,25 +2113,32 @@ getGroupChatPreviews_ db User {userId, userContactId} = do 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(item_ts) MaxDate + SELECT group_id, MAX(item_ts) AS MaxDate FROM chat_items WHERE item_deleted != 1 GROUP BY group_id ) GIMaxDates ON GIMaxDates.group_id = g.group_id LEFT JOIN chat_items ci ON ci.group_id = GIMaxDates.group_id AND ci.item_ts = GIMaxDates.MaxDate + 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 |] - (userId, userContactId) + (CISRcvNew, userId, userContactId) where - toGroupChatPreview :: TimeZone -> GroupInfoRow :. MaybeGroupChatItemRow -> AChat - toGroupChatPreview tz (groupInfoRow :. ciRow_) = + toGroupChatPreview :: TimeZone -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat + toGroupChatPreview tz (groupInfoRow :. statsRow :. ciRow_) = let groupInfo = toGroupInfo userContactId groupInfoRow ci_ = toGroupChatItemList tz userContactId ciRow_ - in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ + stats = toChatStats statsRow + in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats getContactRequestChatPreviews_ :: DB.Connection -> User -> IO [AChat] getContactRequestChatPreviews_ db User {userId} = @@ -2141,7 +2159,8 @@ getContactRequestChatPreviews_ db User {userId} = toContactRequestChatPreview :: ContactRequestRow -> AChat toContactRequestChatPreview cReqRow = let cReq = toContactRequest cReqRow - in AChat SCTContactRequest $ Chat (ContactRequest cReq) [] + 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 = @@ -2154,8 +2173,9 @@ getDirectChat st user contactId pagination = 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) + pure $ Chat (DirectChat contact) (reverse chatItems) stats where getDirectChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsLast_ = do @@ -2177,8 +2197,9 @@ getDirectChatLast_ db User {userId} contactId count = do 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 + pure $ Chat (DirectChat contact) chatItems stats where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do @@ -2200,8 +2221,9 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do 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) + pure $ Chat (DirectChat contact) (reverse chatItems) stats where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do @@ -2220,6 +2242,23 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do |] (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 @@ -2269,8 +2308,9 @@ getGroupChat st user groupId pagination = 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) + pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats where getGroupChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsLast_ = do @@ -2298,8 +2338,9 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do 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 + pure $ Chat (GroupChat groupInfo) chatItems stats where getGroupChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsAfter_ = do @@ -2327,8 +2368,9 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId 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) + pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats where getGroupChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsBefore_ = do @@ -2353,6 +2395,23 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI |] (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 -> @@ -2444,9 +2503,9 @@ updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do db [sql| UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE contact_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_sent = ? + WHERE contact_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? |] - (CISRcvRead, currentTs, contactId, fromItemId, toItemId, SMDRcv) + (CISRcvRead, currentTs, contactId, fromItemId, toItemId, CISRcvNew) updateGroupChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () updateGroupChatItemsRead st groupId (fromItemId, toItemId) = do @@ -2456,9 +2515,14 @@ updateGroupChatItemsRead st groupId (fromItemId, toItemId) = do db [sql| UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE group_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_sent = ? + WHERE group_id = ? AND chat_item_id >= ? AND chat_item_id <= ? AND item_status = ? |] - (CISRcvRead, currentTs, groupId, fromItemId, toItemId, SMDRcv) + (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) From ff7a8cade157b3526560565f804b5d8b3f6d805d Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Wed, 9 Feb 2022 20:58:02 +0400 Subject: [PATCH 75/82] test chat items (#285) --- src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Mobile.hs | 2 +- src/Simplex/Chat/Store.hs | 16 ++++---- src/Simplex/Chat/Terminal/Input.hs | 3 +- src/Simplex/Chat/Terminal/Output.hs | 5 ++- src/Simplex/Chat/View.hs | 27 +++++++++--- tests/ChatClient.hs | 5 ++- tests/ChatTests.hs | 64 +++++++++++++++++++++++++++-- 9 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 32251338f9..ff98129e1e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -79,7 +79,8 @@ defaultChatConfig = dbPoolSize = 1, yesToMigrations = False, tbqSize = 16, - fileChunkSize = 15780 + fileChunkSize = 15780, + testView = False } logCfg :: LogConfig diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 516ff68a2a..feeedeaf7c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -49,7 +49,8 @@ data ChatConfig = ChatConfig dbPoolSize :: Int, yesToMigrations :: Bool, tbqSize :: Natural, - fileChunkSize :: Integer + fileChunkSize :: Integer, + testView :: Bool } data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 1904d55654..1d1c94a1fa 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -70,7 +70,7 @@ 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 () + 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 diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index bc094ab4e5..2d625d7ebb 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2050,13 +2050,13 @@ getDirectChatPreviews_ db User {userId} = do 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(item_ts) AS MaxDate + SELECT contact_id, MAX(chat_item_id) AS MaxId FROM chat_items WHERE item_deleted != 1 GROUP BY contact_id - ) CIMaxDates ON CIMaxDates.contact_id = ct.contact_id - LEFT JOIN chat_items ci ON ci.contact_id = CIMaxDates.contact_id - AND ci.item_ts = CIMaxDates.MaxDate + ) 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 @@ -2113,13 +2113,13 @@ getGroupChatPreviews_ db User {userId, userContactId} = do 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(item_ts) AS MaxDate + SELECT group_id, MAX(chat_item_id) AS MaxId FROM chat_items WHERE item_deleted != 1 GROUP BY group_id - ) GIMaxDates ON GIMaxDates.group_id = g.group_id - LEFT JOIN chat_items ci ON ci.group_id = GIMaxDates.group_id - AND ci.item_ts = GIMaxDates.MaxDate + ) 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 diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index f4bc51769e..e401b4b8c0 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -29,7 +29,8 @@ runInputLoop :: ChatTerminal -> ChatController -> IO () runInputLoop ct cc = forever $ do s <- atomically . readTBQueue $ inputQ cc r <- runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc - printToTerminal ct $ responseToView s r + let testV = testView $ config cc + printToTerminal ct $ responseToView s testV r runTerminalInput :: ChatTerminal -> ChatController -> IO () runTerminalInput ct cc = withChatTerm ct $ do diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 7de744f569..79526e624f 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -73,9 +73,10 @@ withTermLock ChatTerminal {termLock} action = do atomically $ putTMVar termLock () runTerminalOutput :: ChatTerminal -> ChatController -> IO () -runTerminalOutput ct cc = +runTerminalOutput ct cc = do + let testV = testView $ config cc forever $ - atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView "" . snd + atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView "" testV . snd printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 0f0a0476dc..4b95f0757c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,7 +19,7 @@ import Numeric (showFFloat) import Simplex.Chat.Controller import Simplex.Chat.Help import Simplex.Chat.Markdown -import Simplex.Chat.Messages +import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError (..)) import Simplex.Chat.Styled @@ -30,14 +30,14 @@ import qualified Simplex.Messaging.Protocol as SMP import System.Console.ANSI.Types serializeChatResponse :: ChatResponse -> String -serializeChatResponse = unlines . map unStyle . responseToView "" +serializeChatResponse = unlines . map unStyle . responseToView "" False -responseToView :: String -> ChatResponse -> [StyledString] -responseToView cmd = \case +responseToView :: String -> Bool -> ChatResponse -> [StyledString] +responseToView cmd testView = \case CRActiveUser User {profile} -> r $ viewUserProfile profile CRChatStarted -> r ["chat started"] - CRApiChats chats -> r [sShow chats] - CRApiChat chat -> r [sShow chat] + 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 @@ -124,6 +124,21 @@ responseToView cmd = \case -- this function should be `r` for "synchronous", `id` for "asynchronous" command responses -- r' = id r' = r + 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 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index fa0556560b..b16a74daba 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -70,7 +70,8 @@ cfg :: ChatConfig cfg = defaultChatConfig { agentConfig = - aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}} + aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}}, + testView = True } virtualSimplexChat :: FilePath -> Profile -> IO TestCC @@ -79,7 +80,7 @@ virtualSimplexChat dbFilePrefix profile = do Right user <- runExceptT $ createUser st profile True t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t - cc <- newChatController st (Just user) cfg opts {dbFilePrefix} . const $ pure () -- no notifications + 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 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 82916e5ff0..d08f99b983 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -12,7 +12,7 @@ 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) @@ -66,10 +66,31 @@ testAddContact = concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + -- 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 @@ -82,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 <## "no contact bob_1" + alice #$$> ("/_get chats", [("@bob", "hi")]) + bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) testGroup :: IO () testGroup = @@ -133,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" @@ -661,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 () @@ -824,6 +865,21 @@ 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) cmd From 516c8d79ad575778a422dad3a5f170e79c3313d3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 9 Feb 2022 22:53:06 +0000 Subject: [PATCH 76/82] receiving messages in the background and sending local notifications (#284) * receiving messages in the background and sending local notifications * show notifications in foreground and background * presentation logic for notification options when app is in the foreground * background refresh works * remove async dispatch --- apps/ios/Shared/ContentView.swift | 29 ++- apps/ios/Shared/Model/BGManager.swift | 97 +++++++++ apps/ios/Shared/Model/ChatModel.swift | 64 +++--- apps/ios/Shared/Model/NtfManager.swift | 199 ++++++++++++++++++ apps/ios/Shared/Model/SimpleXAPI.swift | 115 +++++++++- .../MyPlayground.playground/Contents.swift | 2 + .../timeline.xctimeline | 2 +- apps/ios/Shared/SimpleXApp.swift | 26 ++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/ChatList/ChatListNavLink.swift | 23 +- .../Shared/Views/ChatList/ChatListView.swift | 10 +- .../Views/NewChat/ConnectContactView.swift | 4 +- .../Shared/Views/NewChat/NewChatButton.swift | 2 +- apps/ios/Shared/Views/TerminalView.swift | 9 +- .../Views/UserSettings/SettingsButton.swift | 2 +- .../Views/UserSettings/UserAddress.swift | 4 +- .../Views/UserSettings/UserProfile.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++ 19 files changed, 508 insertions(+), 98 deletions(-) create mode 100644 apps/ios/Shared/Model/BGManager.swift create mode 100644 apps/ios/Shared/Model/NtfManager.swift diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index a66c3e503a..0392e7274b 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var chatModel: ChatModel + @State private var showNotificationAlert = false var body: some View { if let user = chatModel.currentUser { @@ -20,21 +21,27 @@ struct ContentView: View { } catch { fatalError("Failed to start or load chats: \(error)") } - - DispatchQueue.global().async { - while(true) { - do { - try processReceivedMsg(chatModel, chatRecvMsg()) - } catch { - print("error receiving message: ", 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() } - } + } } diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift new file mode 100644 index 0000000000..9365d42777 --- /dev/null +++ b/apps/ios/Shared/Model/BGManager.swift @@ -0,0 +1,97 @@ +// +// 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 + +class BGManager { + private var bgTimer: Timer? + + static let shared = BGManager() + + func register() { + logger.debug("BGManager.register") + BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in + self.handleRefresh(RefreshTask(task as! BGAppRefreshTask)) + } + } + + func schedule() { + logger.debug("BGManager.schedule") + let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) + request.earliestBeginDate = Date(timeIntervalSinceNow: 10 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + logger.error("BGManager.schedule error: \(error.localizedDescription)") + } + } + + private func handleRefresh(_ task: RefreshTask) { + logger.debug("BGManager.handleRefresh") + schedule() + task.expirationHandler = { + logger.debug("BGManager.handleRefresh expirationHandler") + ChatReceiver.shared.stop() + task.setTaskCompleted(success: true) + } + DispatchQueue.main.async { + initializeChat() + if ChatModel.shared.currentUser == nil { + task.setTaskCompleted(success: true) + return + } + logger.debug("BGManager.handleRefresh: starting chat") + ChatReceiver.shared.start(bgTask: task) + RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in + self.bgTimer = timer + logger.debug("BGManager.handleRefresh: timer") + if ChatReceiver.shared.lastMsgTime.distance(to: Date.now) >= waitForMessages { + logger.debug("BGManager.handleRefresh: timer: stopping") + ChatReceiver.shared.stop() + task.setTaskCompleted(success: true) + timer.invalidate() + self.bgTimer = nil + } + }, forMode: .default) + } + } + + func invalidateStopTimer() { + logger.debug("BGManager.invalidateStopTimer?") + if let timer = bgTimer { + timer.invalidate() + bgTimer = nil + logger.debug("BGManager.invalidateStopTimer: done") + } + } + + class RefreshTask { + private let task: BGAppRefreshTask + var completed = false + + internal init(_ task: BGAppRefreshTask) { + self.task = task + } + + var expirationHandler: (() -> Void)? { + set { task.expirationHandler = newValue } + get { task.expirationHandler } + } + + func setTaskCompleted(success: Bool) { + if !completed { task.setTaskCompleted(success: success) } + completed = true + } + } +} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 34e5fa5bb8..db216b2fe9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -22,6 +22,7 @@ final class ChatModel: ObservableObject { @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 @@ -84,8 +85,6 @@ final class ChatModel: ObservableObject { } if chatId == cInfo.id { withAnimation { chatItems.append(cItem) } - } else if chatId != nil { - // meesage arrived to some other chat, show notification } } @@ -129,7 +128,7 @@ typealias ContactName = String typealias GroupName = String -struct Profile: Codable { +struct Profile: Codable, NamedChat { var displayName: String var fullName: String @@ -145,7 +144,20 @@ enum ChatType: String { case contactRequest = "<@" } -enum ChatInfo: Identifiable, Decodable { +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) @@ -163,9 +175,9 @@ enum ChatInfo: Identifiable, Decodable { var displayName: String { get { switch self { - case let .direct(contact): return contact.profile.displayName - case let .group(groupInfo): return groupInfo.groupProfile.displayName - case let .contactRequest(contactRequest): return contactRequest.profile.displayName + case let .direct(contact): return contact.displayName + case let .group(groupInfo): return groupInfo.displayName + case let .contactRequest(contactRequest): return contactRequest.displayName } } } @@ -173,18 +185,14 @@ enum ChatInfo: Identifiable, Decodable { var fullName: String { get { switch self { - case let .direct(contact): return contact.profile.fullName - case let .group(groupInfo): return groupInfo.groupProfile.fullName - case let .contactRequest(contactRequest): return contactRequest.profile.fullName + case let .direct(contact): return contact.fullName + case let .group(groupInfo): return groupInfo.fullName + case let .contactRequest(contactRequest): return contactRequest.fullName } } } - var chatViewName: String { - get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } - } - - var id: String { + var id: ChatId { get { switch self { case let .direct(contact): return contact.id @@ -292,17 +300,17 @@ final class Chat: ObservableObject, Identifiable { self.chatItems = chatItems } - var id: String { get { chatInfo.id } } + var id: ChatId { get { chatInfo.id } } } struct ChatData: Decodable, Identifiable { var chatInfo: ChatInfo var chatItems: [ChatItem] - var id: String { get { chatInfo.id } } + var id: ChatId { get { chatInfo.id } } } -struct Contact: Identifiable, Decodable { +struct Contact: Identifiable, Decodable, NamedChat { var contactId: Int64 var localDisplayName: ContactName var profile: Profile @@ -310,9 +318,11 @@ struct Contact: Identifiable, Decodable { var viaGroup: Int64? var createdAt: Date - var id: String { get { "@\(contactId)" } } + 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, @@ -329,15 +339,16 @@ struct Connection: Decodable { static let sampleData = Connection(connStatus: "ready") } -struct UserContactRequest: Decodable { +struct UserContactRequest: Decodable, NamedChat { var contactRequestId: Int64 var localDisplayName: ContactName var profile: Profile var createdAt: Date - var id: String { get { "<@\(contactRequestId)" } } - + 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, @@ -347,15 +358,16 @@ struct UserContactRequest: Decodable { ) } -struct GroupInfo: Identifiable, Decodable { +struct GroupInfo: Identifiable, Decodable, NamedChat { var groupId: Int64 var localDisplayName: GroupName var groupProfile: GroupProfile var createdAt: Date - var id: String { get { "#\(groupId)" } } - + 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, @@ -365,7 +377,7 @@ struct GroupInfo: Identifiable, Decodable { ) } -struct GroupProfile: Codable { +struct GroupProfile: Codable, NamedChat { var displayName: String var fullName: String 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 index 46e2a434f6..00d604ccbf 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -8,6 +8,8 @@ import Foundation import UIKit +import Dispatch +import BackgroundTasks private var chatController: chat_ctrl? private let jsonDecoder = getJSONDecoder() @@ -82,6 +84,9 @@ enum ChatResponse: Decodable, Error { 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) @@ -111,6 +116,9 @@ enum ChatResponse: Decodable, Error { 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" @@ -143,6 +151,9 @@ enum ChatResponse: Decodable, Error { 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) @@ -187,12 +198,12 @@ enum TerminalItem: Identifiable { func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! -// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? -// DispatchQueue.main.async { -// termId += 1 -// chatModel.terminalItems.append(.cmd(termId, cmd)) -// } - return chatResponse(chat_send_cmd(getChatCtrl(), &c)!) + 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 { @@ -201,7 +212,6 @@ func chatRecvMsg() throws -> ChatResponse { func apiGetActiveUser() throws -> User? { let _ = getChatCtrl() - sleep(1) let r = try chatSendCmd(.showActiveUser) switch r { case let .activeUser(user): return user @@ -305,18 +315,98 @@ func apiRejectContactRequest(contactReqId: Int64) throws { throw r } -func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { +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 wasStarted = false + private var _canStop = false + private var _lastMsgTime = Date.now + + static let shared = ChatReceiver() + + var lastMsgTime: Date { get { _lastMsgTime } } + + func start(bgTask: BGManager.RefreshTask? = nil) { + logger.debug("ChatReceiver.start") + wasStarted = true + receiveMessages = true + _canStop = 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)") + } + } + if let task = bgTask { task.setTaskCompleted(success: true) } + } + receiveLoop = loop + DispatchQueue.global().async(execute: loop) + } + + func stop() { + logger.debug("ChatReceiver.stop?") + if !_canStop { return } + receiveMessages = false + receiveLoop?.cancel() + receiveLoop = nil + logger.debug("ChatReceiver.stop: done") + } + + func restart() { + logger.debug("ChatReceiver.restart?") + if wasStarted && receiveLoop == nil { start() } + _canStop = false + } +} + +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) { @@ -338,9 +428,12 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) { } chatModel.updateNetworkStatus(contact, .error(err)) case let .newChatItem(aChatItem): - chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem) + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + chatModel.addChatItem(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(cInfo, cItem) default: - print("unsupported response: ", res.responseType) + logger.debug("unsupported event: \(res.responseType)") } } } @@ -363,7 +456,7 @@ private func chatResponse(_ cjson: UnsafePointer) -> ChatResponse { let r = try jsonDecoder.decode(APIResponse.self, from: d) return r.resp } catch { - print (error) + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") } var type: String? diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift index 991679a8b4..eb0b6af432 100644 --- a/apps/ios/Shared/MyPlayground.playground/Contents.swift +++ b/apps/ios/Shared/MyPlayground.playground/Contents.swift @@ -28,3 +28,5 @@ for match in matches { 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/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline index ad2411a0b9..f62b952eff 100644 --- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline +++ b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline @@ -3,7 +3,7 @@ version = "3.0"> diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 35f3cad9b7..0c46429ef4 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -6,29 +6,39 @@ // import SwiftUI +import OSLog + +let logger = Logger() @main struct SimpleXApp: App { - @StateObject private var chatModel = ChatModel() - + @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 { - WindowGroup { + return WindowGroup { ContentView() .environmentObject(chatModel) .onOpenURL { url in + logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url chatModel.connectViaUrl = true - print(url) } .onAppear() { - do { - chatModel.currentUser = try apiGetActiveUser() - } catch { - fatalError("Failed to initialize chat controller or database: \(error)") + initializeChat() + } + .onChange(of: scenePhase) { phase in + if phase == .background { + BGManager.shared.schedule() + } else { + BGManager.shared.invalidateStopTimer() + ChatReceiver.shared.restart() } } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 6d59b19462..9799db243f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -79,7 +79,7 @@ struct ChatInfoView: View { chatModel.removeChat(contact.id) showChatInfo = false } catch let error { - print("Error: \(error)") + logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") } alertContact = nil }, secondaryButton: .cancel() { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 864fa39a74..ccfb4b89f1 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -98,7 +98,7 @@ struct ChatView: View { let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg)) chatModel.addChatItem(chat.chatInfo, chatItem) } catch { - print(error) + logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)") } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index b26ed3f303..f707660477 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -40,7 +40,7 @@ struct ChatListNavLink: View { chatModel.updateChatInfo(chat.chatInfo) chatModel.chatItems = chat.chatItems } catch { - print("apiGetChatItems", error) + logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)") } } } @@ -121,7 +121,7 @@ struct ChatListNavLink: View { try apiDeleteChat(type: .direct, id: contact.apiId) chatModel.removeChat(contact.id) } catch let error { - print("Error: \(error)") + logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") } alertContact = nil }, secondaryButton: .cancel() { @@ -149,25 +149,6 @@ struct ChatListNavLink: View { } ) } - - private func acceptContactRequest(_ contactRequest: UserContactRequest) { - do { - let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId) - let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) - chatModel.replaceChat(contactRequest.id, chat) - } catch let error { - print("Error: \(error)") - } - } - - private func rejectContactRequest(_ contactRequest: UserContactRequest) { - do { - try apiRejectContactRequest(contactReqId: contactRequest.apiId) - chatModel.removeChat(contactRequest.id) - } catch let error { - print("Error: \(error)") - } - } } struct ChatListNavLink_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index ba6082e9cb..bdc3c815cc 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -42,15 +42,17 @@ struct ChatListView: View { NewChatButton() } } - .alert(isPresented: $connectAlert) { connectionErrorAlert() } + .alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() } } - .alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() } + .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)") @@ -63,7 +65,7 @@ struct ChatListView: View { } catch { connectAlert = true connectError = error - print(error) + logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(error.localizedDescription)") } chatModel.appOpenUrl = nil }, secondaryButton: .cancel() { @@ -71,7 +73,7 @@ struct ChatListView: View { } ) } else { - return Alert(title: Text("Error: URL not available")) + return Alert(title: Text("Error: URL is invalid")) } } else { return Alert(title: Text("Error: URL not available")) diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift index 088f336f9c..024c310434 100644 --- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift +++ b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift @@ -37,11 +37,11 @@ struct ConnectContactView: View { try apiConnect(connReq: r.string) completed(nil) } catch { - print(error) + logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)") completed(error) } case let .failure(e): - print(e) + logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") completed(e) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index db65dd0dcb..ba495dfd21 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -51,7 +51,7 @@ struct NewChatButton: View { } catch { addContactAlert = true addContactError = error - print(error) + logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 0170786053..2410ff37ba 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -52,17 +52,12 @@ struct TerminalView: View { func sendMessage(_ cmdStr: String) { let cmd = ChatCommand.string(cmdStr) - chatModel.terminalItems.append(.cmd(.now, cmd)) - DispatchQueue.global().async { inProgress = true do { - let r = try chatSendCmd(cmd) - DispatchQueue.main.async { - chatModel.terminalItems.append(.resp(.now, r)) - } + let _ = try chatSendCmd(cmd) } catch { - print(error) + logger.error("TerminalView.sendMessage chatSendCmd error: \(error.localizedDescription)") } inProgress = false } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift index 702ebbdeaf..f735214086 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift @@ -22,7 +22,7 @@ struct SettingsButton: View { do { chatModel.userAddress = try apiGetUserAddress() } catch { - print(error) + logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)") } } }) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index 19ebf6c778..7d6c8cca65 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -39,7 +39,7 @@ struct UserAddress: View { try apiDeleteUserAddress() chatModel.userAddress = nil } catch let error { - print("Error: \(error)") + logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)") } }, secondaryButton: .cancel() ) @@ -52,7 +52,7 @@ struct UserAddress: View { do { chatModel.userAddress = try apiCreateUserAddress() } catch let error { - print("Error: \(error)") + logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)") } } label: { Label("Create address", systemImage: "qrcode") } .frame(maxWidth: .infinity) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index ac252ed838..2dd5575033 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -68,7 +68,7 @@ struct UserProfile: View { profile = newProfile } } catch { - print(error) + logger.error("UserProfile apiUpdateProfile error: \(error.localizedDescription)") } editProfile = false } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ffd38c3569..84ada554d9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -26,6 +26,10 @@ 5C35CF7127B031FB00FB6C6D /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6C27B031FB00FB6C6D /* libgmp.a */; }; 5C35CF7227B031FB00FB6C6D /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6D27B031FB00FB6C6D /* libffi.a */; }; 5C35CF7327B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */; }; + 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 */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; @@ -116,6 +120,8 @@ 5C35CF6C27B031FB00FB6C6D /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C35CF6D27B031FB00FB6C6D /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a"; 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 = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; 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; }; @@ -251,6 +257,8 @@ 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, 5C9FD96A27A56D4D0075386C /* JSON.swift */, + 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, + 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, ); path = Model; sourceTree = ""; @@ -548,6 +556,7 @@ 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 */, @@ -556,6 +565,7 @@ 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 */, @@ -585,6 +595,7 @@ 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 */, @@ -593,6 +604,7 @@ 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 */, From 5c24089f9ffcd4a8700bf2f6b1ece79609a31880 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Thu, 10 Feb 2022 17:03:36 +0400 Subject: [PATCH 77/82] check group member connection status before delivery; best effort delivery per group member (#286) --- src/Simplex/Chat.hs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ff98129e1e..bb700c3425 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -25,7 +25,6 @@ import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) -import Data.Foldable (for_) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find) @@ -1220,17 +1219,21 @@ sendXGrpMemInv reMember chatMsgEvent introId = sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Maybe Int64 -> m () -> m MessageId sendGroupMessage' members chatMsgEvent introId_ postDeliver = do (msgId, msgBody) <- createSndMessage chatMsgEvent - for_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} -> + -- 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 -> deliverMessage conn msgBody msgId >> postDeliver + 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 - for_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do + forM_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do deliverMessage conn msgBody msgId withStore (\st -> deletePendingGroupMessage st groupMemberId msgId) when (cmEventTag == XGrpMemFwd_) $ case introId_ of From 86c36f53e47f3a7856909c23710492e2ed70b5f9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:52:11 +0000 Subject: [PATCH 78/82] simplify and fix background loading (#288) * simplify and fix background loading * start receive loop in the main chat --- apps/ios/Shared/Model/BGManager.swift | 75 ++++++++++---------------- apps/ios/Shared/Model/SimpleXAPI.swift | 17 +----- apps/ios/Shared/SimpleXApp.swift | 3 -- 3 files changed, 31 insertions(+), 64 deletions(-) diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 9365d42777..8b66fb6e67 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -14,22 +14,25 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 -class BGManager { - private var bgTimer: Timer? +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(RefreshTask(task as! BGAppRefreshTask)) + self.handleRefresh(task as! BGAppRefreshTask) } } func schedule() { logger.debug("BGManager.schedule") let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) - request.earliestBeginDate = Date(timeIntervalSinceNow: 10 * 60) + request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval) do { try BGTaskScheduler.shared.submit(request) } catch { @@ -37,61 +40,41 @@ class BGManager { } } - private func handleRefresh(_ task: RefreshTask) { + private func handleRefresh(_ task: BGAppRefreshTask) { logger.debug("BGManager.handleRefresh") schedule() - task.expirationHandler = { - logger.debug("BGManager.handleRefresh expirationHandler") - ChatReceiver.shared.stop() - task.setTaskCompleted(success: true) + 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 { - task.setTaskCompleted(success: true) + completeTask("no current user") return } logger.debug("BGManager.handleRefresh: starting chat") - ChatReceiver.shared.start(bgTask: task) + let cr = ChatReceiver() + self.chatReceiver = cr + cr.start() RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in - self.bgTimer = timer logger.debug("BGManager.handleRefresh: timer") - if ChatReceiver.shared.lastMsgTime.distance(to: Date.now) >= waitForMessages { - logger.debug("BGManager.handleRefresh: timer: stopping") - ChatReceiver.shared.stop() - task.setTaskCompleted(success: true) - timer.invalidate() - self.bgTimer = nil + self.bgTimer = timer + if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages { + completeTask("timer (no messages after \(waitForMessages) seconds)") } }, forMode: .default) } } - - func invalidateStopTimer() { - logger.debug("BGManager.invalidateStopTimer?") - if let timer = bgTimer { - timer.invalidate() - bgTimer = nil - logger.debug("BGManager.invalidateStopTimer: done") - } - } - - class RefreshTask { - private let task: BGAppRefreshTask - var completed = false - - internal init(_ task: BGAppRefreshTask) { - self.task = task - } - - var expirationHandler: (() -> Void)? { - set { task.expirationHandler = newValue } - get { task.expirationHandler } - } - - func setTaskCompleted(success: Bool) { - if !completed { task.setTaskCompleted(success: success) } - completed = true - } - } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 00d604ccbf..60daa0871f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -345,19 +345,15 @@ func initializeChat() { class ChatReceiver { private var receiveLoop: DispatchWorkItem? private var receiveMessages = true - private var wasStarted = false - private var _canStop = false private var _lastMsgTime = Date.now static let shared = ChatReceiver() var lastMsgTime: Date { get { _lastMsgTime } } - func start(bgTask: BGManager.RefreshTask? = nil) { + func start() { logger.debug("ChatReceiver.start") - wasStarted = true receiveMessages = true - _canStop = true _lastMsgTime = .now if receiveLoop != nil { return } let loop = DispatchWorkItem(qos: .default, flags: []) { @@ -369,25 +365,16 @@ class ChatReceiver { logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)") } } - if let task = bgTask { task.setTaskCompleted(success: true) } } receiveLoop = loop DispatchQueue.global().async(execute: loop) } func stop() { - logger.debug("ChatReceiver.stop?") - if !_canStop { return } + logger.debug("ChatReceiver.stop") receiveMessages = false receiveLoop?.cancel() receiveLoop = nil - logger.debug("ChatReceiver.stop: done") - } - - func restart() { - logger.debug("ChatReceiver.restart?") - if wasStarted && receiveLoop == nil { start() } - _canStop = false } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 0c46429ef4..2334c70e76 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -36,9 +36,6 @@ struct SimpleXApp: App { .onChange(of: scenePhase) { phase in if phase == .background { BGManager.shared.schedule() - } else { - BGManager.shared.invalidateStopTimer() - ChatReceiver.shared.restart() } } } From 771bc6a14dd520c52c0ef2afb9dbd06342b2598a Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Thu, 10 Feb 2022 20:08:29 +0400 Subject: [PATCH 79/82] prepare v1.1.1 (#289) --- cabal.project | 2 +- package.yaml | 2 +- sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Controller.hs | 2 +- stack.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index d072754b5f..1727e1efd6 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: c9994c3a2ca945b9b67e250163cf8d560d2ed554 + tag: c380c795600b887fcae1614a52fb5cda691b569d source-repository-package type: git diff --git a/package.yaml b/package.yaml index a093a0cdd1..d3f458ebd0 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.1.0 +version: 1.1.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/sha256map.nix b/sha256map.nix index f673f7fabf..3d0a905a3e 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "git://github.com/simplex-chat/simplexmq.git"."c9994c3a2ca945b9b67e250163cf8d560d2ed554" = "0lc4jb46ys0hllv5p3i3x2rw8j4s8xxmz66kp893a23ki68ljyhp"; + "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 457a74f60a..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.1.0 +version: 1.1.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index feeedeaf7c..4bf983d20c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -36,7 +36,7 @@ import System.IO (Handle) import UnliftIO.STM versionNumber :: String -versionNumber = "1.1.0" +versionNumber = "1.1.1" versionStr :: String versionStr = "SimpleX Chat v" <> versionNumber diff --git a/stack.yaml b/stack.yaml index 40df8cd5bc..4b16aa4f0c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -48,7 +48,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c9994c3a2ca945b9b67e250163cf8d560d2ed554 + commit: c380c795600b887fcae1614a52fb5cda691b569d # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From 98fc6c6adf2c7d8122a254c453fa552202206679 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 11 Feb 2022 07:42:00 +0000 Subject: [PATCH 80/82] chat usage help and minor UI fixes (#291) * chat usage help and minor UI fixes * update version, build and binary --- .../github.imageset/Contents.json | 23 ++++++ .../github.imageset/github32px.png | Bin 0 -> 1714 bytes .../github.imageset/github64px-1.png | Bin 0 -> 2625 bytes .../github.imageset/github64px.png | Bin 0 -> 2625 bytes .../logo.imageset/Contents.json | 23 ++++++ .../Assets.xcassets/logo.imageset/logo-1.png | Bin 0 -> 19731 bytes .../Assets.xcassets/logo.imageset/logo-2.png | Bin 0 -> 19731 bytes .../Assets.xcassets/logo.imageset/logo.png | Bin 0 -> 19731 bytes apps/ios/Shared/Model/ChatModel.swift | 6 +- apps/ios/Shared/Views/Chat/ChatView.swift | 19 ++++- .../Shared/Views/Chat/SendMessageView.swift | 13 +++- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 66 ++++++++++++++++++ .../Shared/Views/ChatList/ChatListView.swift | 61 +++++++++------- .../Views/ChatList/ChatPreviewView.swift | 2 +- .../Shared/Views/NewChat/NewChatButton.swift | 2 +- apps/ios/Shared/Views/TerminalView.swift | 29 ++++++-- .../Views/UserSettings/SettingsButton.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 57 ++++++++++++++- apps/ios/Shared/Views/WelcomeView.swift | 62 +++++++++------- apps/ios/SimpleX.xcodeproj/project.pbxproj | 58 ++++++++------- 20 files changed, 328 insertions(+), 95 deletions(-) create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png create mode 100644 apps/ios/Shared/Assets.xcassets/logo.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/logo.imageset/logo-1.png create mode 100644 apps/ios/Shared/Assets.xcassets/logo.imageset/logo-2.png create mode 100644 apps/ios/Shared/Assets.xcassets/logo.imageset/logo.png create mode 100644 apps/ios/Shared/Views/ChatList/ChatHelp.swift 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 0000000000000000000000000000000000000000..8b25551a97921681334176ee143b41510a117d86 GIT binary patch literal 1714 zcmaJ?X;2eq7*4oFu!ne{XxAht2qc?8LXr|_LPCfTpaBK7K$c{I0Ld=NLIOeuC;@2) zZ$K%a)k+m-s0>xHmKxL%0V&0TRzzznhgyqrIC$F)0{WwLXLrBvd*^wc_uSc%h%m9E z{W5z3f#4_!7RvAyFh6!S_*<8qJ%KOIm?#E|L=rJQq=gB5C6WLG5;c?r%V0>EmEH#X z5eSwPRa6WXBMs#$5H%GtW2go-in9p>zW@UYDNNWc^XOXZQ? z1QjEV00I#$3^1wQUJ8&-2UsjB-G|9y(LDhMNN3PM{APL4eYi{(m*ERcUnJa{R+-3^ z34^A6;U^v`8N*O6ji%S@sd{fJqD`XFIUJ5zgTe5^5nj414F(y!G&=H(f)Lgzv?>%+ zAsWD}2qhpH7>|TU`X&W6IxDNuO_vET7|j5oG&&VDr!)hUO8+0KR?nh!m<)a!?|%yG zqOwq!CWCcIhE{<$E|F|@g>nP6FoYr6C<8>D?ID9%&5J(4oSbR1I^byW*g@__U z4QsF&uJSEcFeleM3~ChjEQGbHOjsGDMbyAl(p=Ttv9RaVo8~I#js@@Y9C^_2U})yn zzSHU%6FxuY?d;&65MyR({^lU*3$z$ZllDb(o&<7d;A_`h2U+3~BJ2Hv`{W}KEU801#cv_B|9Cm!ynR{S`AMsSn z;7E=B;mb!wx$L;S>yGXG^6=&WlQn9$s?&L%Y1D8TI^MlKB1DqsEng$>f4=xYWBoPI z_S1p!sJ#d2?YI4kPA{k}Eby?F=f-J9zIc`YDl^pzjVm~9ebE?Hn?t0Nx+la|D0MB; z9)2xv1G>a1|A9kQ>~DV<=X3-4yC&n!m8-3K#P z{X@0zRuQsy$+N ziSCoLJU{Z$nQy4A4Y5UJ07$5FA~qL2%Q+cLaqDU?Lz3?=BC5;Nk6BbTmmceEaM>-Z zi>O&-dSE=%ex;vcvCOk{*JQ5^_4M z4lW7%l9IqY(z7pV(?I@@8=KPFO82)O{VDI18-*d-k$YmI^XiuPs_LuFw<^ZcD}yP5 c*NrbeloN*74g`U%%F6r~k%+>C^#XapzmV0H-2eap literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..182a1a3f734fc1b7d712c68b04c29bad9460d6cd GIT binary patch literal 2625 zcmaJ@dpuNWA3rl=+=}acf|9E@P=bZCA&+qg7et*|Lo`cMQ4SL!u zv;hFnqx;f=RIA70r>U;`S924)Rm*a*H%lB0$B2{JLJ07ThNB>m&SUR{f*^KuO5#1p z6#!6H+z^(S#qg(aU>=seh`~yD0u>toT-_xCHYXkugHg~ylAk{k$56lW5JxEB2QU{v0O z(J_=Dn$JgHsuL9xD;5hVI9zgaGB()}3k!GR2xKyOQG-ZyP$3*dDSRx+6H zxzS&ah4w`*P8AGpv9Q5%s{48!i53cI)dGsN^YTkva!Csa-!~y{IALumC5XsY* z;oO9fP-D5HNp6GjVXS9_c1V2u^I_zB1-k6a`@n;|eN2-wq}`FLV<<0w=RlfKU9(3Z z?Vv$*-_m{)R9A=k2=5$JrJ5 zd(x-6(zYwCSQA3wWMBj;Lem(jL~x}3pjUMga+Tt=q9Zf4cjQq+R^GwOxB}onmdyq9 zYa}1po)-)mjV-^ZRfS$nm0JP%%2J6zkxp^p8J$PEwHnnPw39eZX}|bwVDI+Gee`@Y zbah4{SeoLiGPW@75vPCvM=#55zb)v1eNE+tfD*T%9$`a#UqDqP6flo7k-aV>IQ3KL z?3H`(H3`?q)i9}4YoPsfZeLPwKtG(KQ-oT2jcN(B%hrz*1V7UCp6GY!F4e!okh(0O znQ=jWE*4#p8`djsr?kI5jXKJRYt>(U){i0emy7~ePChu6oUwefQNQixI-(=d{P1%3 zhx=v2`Ry0lVKW&Jksh#X2ZBp#{a!;N+otQU!S}lvS5Tvvl5Ubd2b5Jj5-;BoY_WOF z_XCPI9rvwO_zYof?DOK%D7k0_M-eMq1#4^uYW@wUg*5e?z1mhW|GkISQ*)gK!lPx| zhZQN7o3b?xTTW$o)&y=wPN6(!-WiNpD#qR}nK9og7lxJS9YRlhEp9)yU^-uiJhow- z`8UtZ449xibZb6f>W1(}6}*;8Q}D4jvc47_zV#=gHPpIg&^BV=sY7Dmal^rQ{Rb1n zUwQSwn=K>Hdns)-UfJcmNaEkVZt&=3p#x^9uRr~)MJC(+R7*|u#l#|6Oe!OSxM_Eu zmB;$9eNW8?oI@Ao1juH&%}d;U z?#98zrD2Iola(vNeqXDEj5{li7yeqImbZr^`ax#dw1QXei_~7G_g(WFx2Du3&m=l? z7h;1<#irByqG9b@3u(qlI+?8(e{@D`x>QxAscV^@j}^G0H9KoHh*`OVvLl5^wL?J< z7)$I5W&Q|c2#?m>)|0U<*(h6S(odPBl0+QpHsP-r8hDCI;Xy;ZB-GTjC{Lh z)^{?@)XZUvU2)|rYeZga0RK+{;)>14TJ^#VgLD29(mB!`H~7S*Fw{zJ%hPczWn=cg z8jH%4)vX%o*KhVWOn7IlqI@$mJZW&H8;wZubZI_Uwrk`&rADaRwb@W?@%Lq;XVYdZ zzbfh08?cyaez+qbJi_UZNiw(*%k&9+amj>L{ED$OWuQs3t3SxwFrj;;X7JtUOggr3 z9_gyPyNb>f4!Q6KY~O5*EcJ8lx!Eo+mu1XJ+Yaf*g#ElRyLa`VS#Nr;#Tl#HQCW>m z{&_c0soAKyl5Hh_n6KLo+?X66U)GDrzLZ!MuKsS1=~Z-jmeYyn9r@L5{%zdITF>DU zc(z0NN5gMd71f1LPTcD_?PI}M(r1raF|bl_rTXz3>u}j*j^Bmd){0~OhHAcdT%96T zl^I$j>vYCuJ?O7Db;K6G{^kavEh#naE`IOB!FIb6?Rl2b>{14>p?RueVYk~ro9y;T zIrcx#*ZIGkiL#&hR%UZ~U8&hb7!h+vGUz&Kgw@+NpF@^rzAM$3da`Mn#XcKJdEb+n z%Ja~1JE|B-plr+1ckkS)J%8tndxzxYNf*b|;HiBz2ekdat!a4bi8!V6uKj*dC6Dra z#ewE=I4u9YXWc$ zFQ)EwjtXc}@pjCV#OF{`{F&M=E0)#J@Tkkfv83XA7q4{3`Po^?`^#!I#t(`mS z?yFbdpa!*s0@tn$0{aDCQgU)Bq;savHLt4{2qzE7+ W4I>>0bz>}E>ge79v}acf|9E@P=bZCA&+qg7et*|Lo`cMQ4SL!u zv;hFnqx;f=RIA70r>U;`S924)Rm*a*H%lB0$B2{JLJ07ThNB>m&SUR{f*^KuO5#1p z6#!6H+z^(S#qg(aU>=seh`~yD0u>toT-_xCHYXkugHg~ylAk{k$56lW5JxEB2QU{v0O z(J_=Dn$JgHsuL9xD;5hVI9zgaGB()}3k!GR2xKyOQG-ZyP$3*dDSRx+6H zxzS&ah4w`*P8AGpv9Q5%s{48!i53cI)dGsN^YTkva!Csa-!~y{IALumC5XsY* z;oO9fP-D5HNp6GjVXS9_c1V2u^I_zB1-k6a`@n;|eN2-wq}`FLV<<0w=RlfKU9(3Z z?Vv$*-_m{)R9A=k2=5$JrJ5 zd(x-6(zYwCSQA3wWMBj;Lem(jL~x}3pjUMga+Tt=q9Zf4cjQq+R^GwOxB}onmdyq9 zYa}1po)-)mjV-^ZRfS$nm0JP%%2J6zkxp^p8J$PEwHnnPw39eZX}|bwVDI+Gee`@Y zbah4{SeoLiGPW@75vPCvM=#55zb)v1eNE+tfD*T%9$`a#UqDqP6flo7k-aV>IQ3KL z?3H`(H3`?q)i9}4YoPsfZeLPwKtG(KQ-oT2jcN(B%hrz*1V7UCp6GY!F4e!okh(0O znQ=jWE*4#p8`djsr?kI5jXKJRYt>(U){i0emy7~ePChu6oUwefQNQixI-(=d{P1%3 zhx=v2`Ry0lVKW&Jksh#X2ZBp#{a!;N+otQU!S}lvS5Tvvl5Ubd2b5Jj5-;BoY_WOF z_XCPI9rvwO_zYof?DOK%D7k0_M-eMq1#4^uYW@wUg*5e?z1mhW|GkISQ*)gK!lPx| zhZQN7o3b?xTTW$o)&y=wPN6(!-WiNpD#qR}nK9og7lxJS9YRlhEp9)yU^-uiJhow- z`8UtZ449xibZb6f>W1(}6}*;8Q}D4jvc47_zV#=gHPpIg&^BV=sY7Dmal^rQ{Rb1n zUwQSwn=K>Hdns)-UfJcmNaEkVZt&=3p#x^9uRr~)MJC(+R7*|u#l#|6Oe!OSxM_Eu zmB;$9eNW8?oI@Ao1juH&%}d;U z?#98zrD2Iola(vNeqXDEj5{li7yeqImbZr^`ax#dw1QXei_~7G_g(WFx2Du3&m=l? z7h;1<#irByqG9b@3u(qlI+?8(e{@D`x>QxAscV^@j}^G0H9KoHh*`OVvLl5^wL?J< z7)$I5W&Q|c2#?m>)|0U<*(h6S(odPBl0+QpHsP-r8hDCI;Xy;ZB-GTjC{Lh z)^{?@)XZUvU2)|rYeZga0RK+{;)>14TJ^#VgLD29(mB!`H~7S*Fw{zJ%hPczWn=cg z8jH%4)vX%o*KhVWOn7IlqI@$mJZW&H8;wZubZI_Uwrk`&rADaRwb@W?@%Lq;XVYdZ zzbfh08?cyaez+qbJi_UZNiw(*%k&9+amj>L{ED$OWuQs3t3SxwFrj;;X7JtUOggr3 z9_gyPyNb>f4!Q6KY~O5*EcJ8lx!Eo+mu1XJ+Yaf*g#ElRyLa`VS#Nr;#Tl#HQCW>m z{&_c0soAKyl5Hh_n6KLo+?X66U)GDrzLZ!MuKsS1=~Z-jmeYyn9r@L5{%zdITF>DU zc(z0NN5gMd71f1LPTcD_?PI}M(r1raF|bl_rTXz3>u}j*j^Bmd){0~OhHAcdT%96T zl^I$j>vYCuJ?O7Db;K6G{^kavEh#naE`IOB!FIb6?Rl2b>{14>p?RueVYk~ro9y;T zIrcx#*ZIGkiL#&hR%UZ~U8&hb7!h+vGUz&Kgw@+NpF@^rzAM$3da`Mn#XcKJdEb+n z%Ja~1JE|B-plr+1ckkS)J%8tndxzxYNf*b|;HiBz2ekdat!a4bi8!V6uKj*dC6Dra z#ewE=I4u9YXWc$ zFQ)EwjtXc}@pjCV#OF{`{F&M=E0)#J@Tkkfv83XA7q4{3`Po^?`^#!I#t(`mS z?yFbdpa!*s0@tn$0{aDCQgU)Bq;savHLt4{2qzE7+ W4I>>0bz>}E>ge79vjWk1tfV7lDr*xOpkV=<;(hWoB(Ea_n z&wH=F@3+=0V6o0RXP;eX$8S%Fijo}ueTw@iC@A>yucXybP|!iZ-y7Iiz)wymYz!0> zS`>Nd7aDGmom3nr6U~|ypT?1TVwD^5wZ-$ewL`JTYA>jVP@kl~)J|fcHJs(o&RD{u z6Tg>Y{sE1dDCI@sh7xhC6Ge$@P5-UrLZ^<>-jS zqY;)!#KFu{ghMf2`TqMce2kV^$D)EAjIEN@`YZT1-*cCwm*VBu*!LBx%SaK76wHxy zs06hCe)6RZJ?tl+e?+U|E>3e^rcvv-B)!NZO2}z3ii72l+hhE7J4y7VmwpBGT-nPs zS_@ubHca>Xmkb1Yac?!H|GL&sBELW-Jr}$mgb4%E?y}=P(wTb^Xh~@5=>!PI-Umx$ ze-5Yhdxnwq30K+h+o!&%gQ$W9u?Xv@_xszcSwIonbdikz5(!W%Nxw9*fi?= zZ2vdawn9T%A4@*RY3B!s9DR{K=ji_!?u&^w@ClrEU%_Q|f?Nfvog%1WxH^&z|1qhU z0{%lOl{9T+ol{b)$#|sC6MxsF32LH*U{*DV!Zu6ifBd2K6J$1gsrOvQurhl&9cAB# zsTKp%j8CyQtVh4&%^ykq48l+d1vlaQF)ccDlG>jM`m=@eq2}wdUFb4=*l-o@>GajaWb=8N3XCU6%kA(A^bgNefHvy~cK4k>@vvPCHId5+OEFpI!_h^A&r9Bv;UZ$B=~VgE5}^vcEhp3$4$yq%fd}iO|JKcpP@K; zo0V_y5FK=SL4UDci%!z-ZJq9<^HvMSZ=t&1;A>6AMeX1Vp*l_2tR)M`<(&8VUqlh1 z-tV7rF_r6T!CN?v6gU_cc-->5{K~9rvl8+E(ed1f=`TGR1NNSrDchc^aHK^(&uhr5 zE73|mD7r`WAy(a)Q3DX()1L}bf0U&}I%5>`PP|;c;|;(0t`$qLTHFn9hh_`e>j6@F zD$JPvSduv_S}Gg&-1?a|rdx#Zz=aL|eo??VB~=moumcMyWXX*8ueF?LsZ88+Qx#<) z-4&6}8?L&$_UDPl&Wr%fjP@%Eul}MzCPN*glafkAnYK&#H&xqCjRMR-zhYiFwU9jr z5E853d!4^FW<=ZJx+CHo4L((p)=ec)5*Q_Q6Qb*!qm7SX6g%lx__H;qOol#Yr>>B< zA4{Z5F?q`n_LU;~IgN;xb|FFqqSi6A@<(5O>RT=@3`lD|bLlkpoP_q~&64I2AwRaI+Tt)b%~@rZUy%TNkjv?`R`d zP$a*<>0c5M0qm}SFIRG}Z8a~{t18t!bw!5-BtK%t_2(Arv}i;LJ4q#H%$ED?_Yf_^ zFLYba)L22C(Y&;O$okx>1uT0@aQd>N43E=bDD`m^3WAYZV(^dIq7uCEmkh@3_mUha z!z(3QlMPY!MG~YxrT;?^3JwP&w{0`+h|BzLaJ~WgI!#q-zWS0nJv^4rJm-uv{gtA4FNKQm8vV@m<#v!Ut z;2VLHrCu*M&n0c^4}E@d*lt!`%}jJ%xp8wYQ^CIB2IMn7sXNlCWa-F}5dF2JKMbZd zMVlNxC_@qELh$0{!4#AC?0cdp9`t|E#a4mT5&wDTeKFLYe%7zO2~+VhX6qSXF0qCd zPW)b#;#<-}izD5|{KIEjzhBtE8|U?a&Q87rg)l@DeW3Lr)-9P4l zO7JRP(q;ByNb=aGfMnPcS;%%}$l{}U_*9sW4Ye1nq4q@Xj}0>zMo7BsOytoz#MEH8 zpeYxH(%^Bnj3~Lu&PJmk0zLfyFzZz-4jSNAhkQ31Q&`M4=*)-&B*^saJ4U3QwG*NC zhOx$k|7AD~kCG*a8V;fgmsTeHwz2XH%-y2;5H6$K&y^u{_x@%=3e(Rjmn$hYx0Y;~ z%`YB$T#nV^dA-Q7h;#zH6=eZUB4vm|w8@h{+{l+|oz5=zD6eqBMU|mnKwQzCOyeR$ zZ@HwCqi0x9U#kZ|CF;XVl39sJRg2Zy*wlfy*JAd%wf8?6>G(>raCMu6s&Hb)${73j z+4|)OX=cF=lO6{y1NjO z#sUP{XMQjC`ArIq+8Y%tlk++4WuJ7%469^m+M+xx7|pJZ zzQvfjvYrAC@YD?K@(TT{^^&>-`RrUo37D5Vh#(NElPb<<#4TUw?X2U^8hm&`7sH_Q zwa8^tFX*^n3>xXZfb(p3SyY>T`ZK9-T&fWHT-#kK|un0KuT6-clI)0@YvWu zOLULYP2SdLTrj>6LWFlrv!kOgxMiWGmqeW4gfOiQph?zW-O2vhgz4sw7Uj2Y{OCq# zWjOhka?wZPYwI_)ea`QLvqRpn)p3(`w@gWMj2r(ThS^NF_?@;guz_Yh@Faxj8=~$*FrwF99B+^$ z5!{enlVeB>&>k)qgb2)6ex|Sa3>oxc^kv^6%3!{S7};dbN8i=_?x9GYAi+G*`|?RcxvUK)wAIgD=3Eab9jHxW&W z-u3tyPiz!mdebOZ!GZm*_Yh0V96xN5fy2hXy$$yZ#L#ccwm;Oo<$uKH@@Zme{F z;_48U?A#u7PxMyA&3^B;(>7}n<-Xn{ywxb4Cu0R4a)0Eg`c-g&7D;s&58WT*Q=a7$ z<}1zKB2?P8MQV^uX~2mY>(P~A`RyAI@{urW9X*Q2WFpAQEgrGi zD9`l|sBC{uRxVOcSW`v51m_rWlB9v$k?o*tsLo3apoPKm08eI_B5t1{u0Uf%D|3 zf5Gy23Jd)u%SM<}M46H>^`SQmdPLWd#j8reM>FaD!?eB~%=cNk^fDJdv6m;=9Mkhl zX}UV|i1~6dsZSDW5t$tFHjcD*r6Z}Xx}eqMGH6Ki_juA7*RKekBmXUvc`?1KfE8`_ zfb;RU`W`RaY{I}qemqPdNV*RTQ99;+8O{M;;;NvgXYoZ{i~grQZ`Uak!_f-(TzlK2 z`gW(8frIB;X)qf+Ord*(9ZlQZHI%=%j(t*oZJ6O-RBkwIc(`b5NVrey3u^fkJPcX) zU?z_$>`4&Pf)kH|?jm&8C}FZJUO9BTXDEX-9t9fe!>H`G-pwwiccJ8FFH zk2*y+_z04g2_~SzJ>UjrP(cVd?7o8FnveYV zdqO$q&(;HldT?DF4=VRT+E|B+*NcNbBP6guj9hYPwDsQpn&y$>Afb?E#)4UX@%>)< z#fa+yNWfq$sk)**_h8A3dh_FsgxtbYhnoSE6MkaQ>nCY&=}Z&5@K5k2ULGX52YHsv zY7dxSX;toE#Z)d=J`Mhri;I(qds6&1tgz~#-Rs-KUQ*l7YkObTZw+L1s=Hxq=zXa- zoI9a)+brDpePBA}uus2^`LQ;J{2f?-Td@EPuHP#R#)xGEgHQ$3J-!XmsL3oTcQ5yZ zCEj#|(6!3PI~?>2^DGS_dcb=A14C;)eR?kPvxiIJb=98$O~W-%d0guqM{|``DZ2Nz zeznDG%*?xjH^HZ>W}OGEk%=oAmQ`zx?=6KKNu?Cm&9IAT%gV_MT>!_27!?}4N`t?d ze>s@m{MCMwKy9rj?6`@P)2}zv0@B+X3p2Ojl?Y`9iSQ~et#mxIx7Ral@VFidYptPK zzOQN^?mnZ7;+1&5XSV&xARljaoHV3 z!JAt%m)W!0j1z;+nGebu5(ea(u1gFp3pO1HJkg|RE3#k{tOBlYmIp+oMpPDig8+O- znua)ikrTyF@OICJiN`~wc&o~vs2F8Ym^)=|Lf3)CwL?;np0P4?aC1Q3x-VXVLx253zekMpy9rM_(sh4QtPfoD99A zSOwursYp$|kPq?+U+`fDY+aC!CocAP<<6lQk7zUmFS)L*yWiE*pmmrGb&=(v}IzrftcZKGKo=nKHTkD3&w(`e5<2i z@Mf=JCI8ZEuQ-^r$YS>f_#%(%sdFzl%LC-Vi#b^QC5%}n?U5rbf|02;H%}d+_nGv5 z|0)-tAIi9&yQ8+Snt6#I9|NyU5QOnF6_vrg1z#*AgTPULui@Ng%XBIcX`7v;e&3bl zExR}5p_4vzqlXF^PVwcMso=r0q_xfry(=1+N)Q z+a+B=Mzj`QRlVsSGyAM2R+(hhW~INM6{eTs9k+a!eE=xzL8Fe%R2en@epSbqo;F17 z+h#Dz75+~N^b!1pTbuKtY?N*5>=4|J{$KmZf{2bihIr6xKx2qrV|9I+d8`n=ZOLu) zJrkbdbI&(VKy-K?qc`kbhV1vA^A}KtXb(bGkSyi63U_aq2$Dc+Bm`}AcFjkPNXuJ6 z=*Tod#4Xl*piBWGNI{QPY1LI0WXPg~C*pXl9^ZCz%SS#fZ|^q;DJn1!`_Q2b)7ffA zR-XPGgp`k_qg?f=q2gT~USd>Yso~CB-Zb8Kzo}bSZ%T7s;1Yuj9a{bL%B%!ofKVR2 zj@TG_qrz3nO~r33+6%bfh#~E6t3pKdAw@d5kF4wb{+9G{%31W1oVg>ko;EviB2RZp zvu{iz9X0+v?h!&>eC+vX{P}4?^7ssO%V?Nf27H1^Poh~2Ivhd1Pcz5$Az`~={Y<#<2DUL;|kqJ7&QADYjo{ls+Vj zfx9xHBWWt~)7Br7NZqJwwg`J(1Z-R6((dU>mHd~UMPPI*DDORY-XdP~ z!W#5q0TtA&oB~WfVyNfffaOoETpFq5F9x!=?p5&&OUq}yq~$=9J(`% zr}9|drc(AZMv?m%ojuIEp-V>vnO8j##k2DEs5c42Ni7!U9mf&Hj88ZKB2+|0w z(B}|oZ4IW^S_<_L{JxWYgM=Ph&k(hD1sXe57T*(njI;9cf5k`n6-$O-7I8s=I3K&XXOW?O>Ab!z7TiAi zaRS@IW8KaYGO1ZT{8R93j zfy6ZiI2_o2F&YZWflqt|L*=7N>-%?Txglk@kFn{lg$3nodtTiVX}0jbz37ag$A7T? zR-{=L^E;Ll1rFoSC?))p7P|_=JL37s4}_@7ujB^&6F{J*o1RCW)swzq1M2Iwg@_Sv zK_ejo$Go}tTS597b<~fiHBMrppk)s%31>GSx%=L|oaeReC1+B~ujUtg{PvA}wVyMzQ|Kn2#}u=-&w12w3W z3fHc2sg|szso#M+E=^E(mPDxf`o7qr!De1>hh6SxO0jHX7~4uYzXRxtItEP|MYoS( zCJ&(T3ramER|UkEx7-i`Im5n$1h6 z>Z9F|Mq%3{_Rz_Rk#IeV;n2I}{2{7a3-}4@6+OLxkL6OOUCvwWq4l$l`%j|H@Np3& z-NAre`h2+bS^Zjv;uB4dr%V+W<+Zo{k{KjuFyg4G!qsqmR_}FR#94xc<|Tfu26LE! zqmBCRn}-O->jxpdIDpnw*=Q^wuJ%>q7U@}2FI6D@H9z{!$80f(+hA;j9YTpkUjVvH zv+Y?y5d>f#Pj;L;Z-HLEU~n)>$LimL>*Qvcb+l7$nLv-3y-bSp;VW*9wLyTSQZj{} zxW}{8ZQ@_M+0^8)HTUz=?j+-dFp{NwIE1&nkWDeuQ|a%ncqF7Krgrp8J~=b*N0 zk=w=t^@iqb>O~7i&-R1}SZOC#S6`&nwO>Sc=TxaK-v0nI?{r->{pyHeHhYGdRaHwReEhhhpaxHa zsB``;iCw0rzjdz+%?CFfDWK0qehzNh5cJ628X|1mlj7HRcztlRM=)2!KXFSHi^;(N zT8v0*Vv6k#K}>jo#>OmIxe*-%6IkDaZjDOjS`J4eQk$*CozLk}wm{_89}1>}#?R7V zX2S!?YlsK4`0EY7-?Y~I?c(;zSmn9G*E-b3=|0vlgMgq1i1S643M>-;a8t3Ts8m20^qgIX}w_He<@sa zoJW=di^#$D3I0WQQQ9lLLCD1BaM11D<$O@dLzp`EWcmLj^CAN>DTDoWh)~I`?!Rtm9Rj-fZs!%riFH_QZ>PLi8uV_=UOYorNy<*;yYUvS_3cZmyI)@DM;q z&%^8y=#`t`@jxcC6YOgHE4N1(6Lt?L!Nj;2g` zI7F~;hVhhzXiH|L3WzGmP!zdc`*M~{jR$MC=L}`jLv>$2 zt!k)k4eYwoTd{WoKN&nTJa?b=O7WR|v$uc1=Kf{phx1afaz#Cz`uB-noms=ogkP(v zmo0$A{fWhuZ|EsjY)6y;VRzqo9=e+=pJQyAdf$53sOaF(3Gsy{LWg}z=}a8HD~3B1 zn{+kNtAkPmQUe^omj;rj#U-(%kFWB1Rq+hQ`jy);Shz2`rnU#pbG8U&?HKj%&7(Wg zd_G_gxQx<8ZIt>6MOJdGwJ@O9?VC3!KDz#5Y4&ThU-O|->5UbEq~6dAsC}i>DF)v~ zwXCuM^`z@QA=*weRjZOxsbz4M+hjsLm-E`yG6r^8?6-sZr$8i8@mM%q9$F#evQhVK z1#`jF7(7}llof!vddkMDmr7ROMm@Vi?~%Q1K)fK(^InNHdkK~0s@D%H;-jZ$`1a6l z)AN7zJ}=g%Z#Z^_P{~U&Pi(Z)FH$sfDXAR7y~u zkp>SG(WzHog9BzLoiH1hR*P=P8B06l5L0iaw_^a4j2o1tTt0qqdq3-z+{Wp9w)msC zurY`WkpI`wbTYk(gI!|O^}NZ%LT6p^cme%UAk;#sH_we$EZk0n2E zjj`>-9w(lZNyKCG!+?(x+cys-qlVA$XM~1RtB{7_jhY`qSd_RMN;%u6E>p5^YLJhAa?%=%I7{@ptcZG^(nRE3K=Mhp4%(8ag)N=aZ@ zn|b`>9+CBBl+bhUW|T79?}4`dw4Q|%<`h=CXZk1}q|^vZWS*8A+DQ9PrzUf=>LOh( zpY>b;A0_nSk`z&Bh_aUST&aL4X4YOfjg8XBdjX{j1>n@?ZTNU^o`Ujt`yOmK3Wb?< zp8;i$m%^CARlfGtuCFik@S(gsri*)mQ z%(JvH2y>(qgPg>)F7Jn5X{jC-Ax0ufPg_mA<{A!grR}SG5?H&ElA5|qSy}{3o^LjH z7mKW=v$lp<5Sy`;q0M6(d?FVuKm1)`z+_&SPiD^o;#sgsr050bh&Wb|_Fv7N4ItTQ zaG2!gTHeG@&MkuS*OQmZRu-|*W;E-4*7Bf@qE2jx9Gpu0lq7-2Uej^bcJCx*tZ#BQ z^MJ<}etd&+MWl`JBUf|07o@)9eU;GXn zd7swX^uzgYoYfRqVJTcR>$IF%%XAfnLLG1Bhvg{2(%QjC-XuKG)zqmE1SMQA47DIk zvf{=YJRJ|Qes7K*gt6~I@<-C%(;$zgDoqy&JHx%D55D!99B`Dmo-LCo#hF>zZ1r^# ze1>t}D&d3T-3o_Shwx9@faL#k$C>9;f?^%`$L=dA>hsOYik(_XN}PV1q{M?FN-kGq zbK%$Kk-7DYFbsDSwR4UY9F1T9G;p7~NIl)^1(W6clXyI<&HTj4^G2v}DSXppBkK!` zYa<;i=s@I~CS*TF`zB0i`5M)Ck@TWx1E{d91hNgBT&(3UI3H;9Ft-~3L3+>PEf8Sw zxgkd|^!t#NhgA=u$fHWr`Fs2QOod^hGSaR^3UTcc&);4G?-a!pBf9HXxD()dY#6x< zTgE7^HaT;WVKWO<+!^n8CK%Zq-1;~*zdmQ@y0=p1ka;45Fg@u~F<5lSuihGP@F7^= zh<7M3rB;%JhTPUnGJzIvcByW-rRPZ{HdC;Z(aN?g>gs;pIY9a^8rx&N$R)*8%{r`ho^=!<~`tM~VJusBj{?>+Dt%hG#+Cr!>wS28l< z)yMZT`b^a!ORn!dpl#HFU2fz!pyeq>@0Dkd#i`@M_U3MU4jgXIxo>|(OJE|FzV&?u z80SO^>NaQEj;e?+6PCGg$RTwu834+O# zyb0n)g`ORDqg-DhH<3^chxz^2`K8~6gZB&c?yAlu6-_BL&3Z`mAV)>a-<#^SbC)Br zA}izq9G3#V;XR@vH-tpsFaVCCi@?QYhUWHiyX z8kvMrISr+Jg~$|}=cOTTcizZ;um?x{nwjK=y+(=f3a z*Z44BgST{MbC^I-RLuh{?Kr(+&Pw>lj=k#liw5s437X{{l}SmBtC@y}QN-AJ?1uwn zmpk!fp&>Yr9!l6)2h&_H_JG~cjE_devX#>yL2?DXpl~d#b`Sl?BBk(tXa*LdgCFtb z(RCR{Zi^fMtb_kK}_P(z9Rr;Q@+CT z6~wG(M}4BlgVwP$u!W9!E&&_I4zI!g`iuL9;YZ=lp#92|t)zmo1!<-@I<-7_ftl5a+%|}|Gri{kG zE(HV%#KoLszwWXM`_QT!bwj!u0K8?u>Kos;Oi%pi-RB~-Q6P+ZTMVNVNBgO4bV%7W zeSUbj5Pd2K z>`fRKhT9DKv5SA{w&7Gy)?CC8|Ilv20Q<7PDgcS-1j*|ax5dFUzTH}nM8?4$c*`Rq zMFvK`n{L~f-|zRg_K+Q?Dfs!teJH~UjS#4wd^6qqPCZW54_EdT3VO&~b*lIZRLL^; zHjyKUS+T&I1$1yQ9uFIkc+lQOnNBSc)`VT??$ktD3}i;suH$rJ>ml(WBkyBN!;mrj z2f>HVfCD@Ov(>uHZ*h8((dEv|oB8mm8?JKi)|+jcm-Jwsv?UKM#Iz7#y@-1lgIDy&@7ToS(peN>a{!eM%SFDEll>A9_7i;-iRdKMkOG^D^%85?g3xTR!;!7DK@=z zn|@#Xu|#Yjw*U8l5TjYjDQUP2#uNDk&*i z-o{W7JwtcdE`y@hl)L~6 z6M4~-|CG*7zeZ2L9(+KYkXB>dRq)D4`D67+Bb9$HC-)e_^zzuu1NU-V`T@bhPZICf zlY7A9MlNq18R!7)9BZHkX&;3(FA{1V_n%EF$lmn~I4;^*dc)-B20PqU_%4u}K_ki| z#Xz5d+RFo(Maefs4B6e8$paoC=Ox^SfMcEWAfF4Egrq+pOaP#zcD|disiKf%?bzHq zV8yghna5DPhrEp$_Gk3(YhP;AjAfjmrjU=kn%eSK%*#8TC*sRw=Df* zrT_z- z4n5m~(@L6RXj|~98iPpyI+?aS=uzVNx<*Gth4(p1{Pm2FQKBA2oLu`Ql;9S)JAD)b zsOq#CkESf^eaDbFt$%N3+e$YvSf=va$6cTYZsKk<@bF7v+nB`Of*j*KlYqYJ8 zl4;o`H3&tfsS5|9gF~4#d(v|~RDH|F(XXeJ%7a~c&?U4!$7tjc88gsVO%wA9gpJc) zzNn%3+{AGZNqBMtgg8F-X#65D_ZUj@D^{O=b)-HR0T*QnpqxO-_RJ~ z14(}zH5qjk*TgR`VcW(P@$dG@59FX06#CmCCo7oa08*s60@uX%4F+8NmQdx&pqRFV zzg`iOsnPZdgN48++$*U>S;sIX4)zRxqGIJA0I%d%g8_SRTNSQzMIg%Y%t!uM5sRNW2D`an^=pRhMn?7Oat0!dU5AsHO$HrP`f>xqS(@l)>TH{^lb)m6BzU4LmFR#xPHO`)ol z*BIQ}{PIx3X*6m7*HHaKz~-z%nqo;2VxWy0VxWFFiUt?!AQ(}cWkQPuAbDjjGU%LHw2%p9e=@ zN}J~MKI5fu{_Ua+0dlHe;`}_jE+__gqSw5(9JA9uu72dIKnSG7K+%8$GBvhvuK7P9 zJi?X9F6<8ME()I+8aVyVIJ3flk?V{wZ8Wp4uU@;B7x#YaXQ$QIaZNZ!xF|-p7&02~ zg512T@`|VN97l}{!3heT4LZs4T4fQ8{RqUBh?<-7@KAt5MJDqB@Dgl?|5&?G*U%c0 zEwyJkf-#N}l%-$mPBg)fgqa;Y) z>Ec=3sG|lGd|$thNv^soTsQAu)_=>%g#Tck3x_+M;B zeD+Ynn87X2W)ea&yPdNhy0iR4J$06!xvkYvaBB+%f4i^Oaw=Tnf*aRBsH+SohvHPZ zdws8!I^H;7|Mcj^L9Air*%dbZ8eS#F#ez!gmv-<3JLuo0D=Him>c}mA#>nxDF)pZ9tElBo)t%RCH)_jzFYlXjs>Y}Jho_;9iY6YY{25EfF z;23HJCt7-+fr)v)^tYLg;TNEj=6W{ysL531kq^cP&tIoXUon670cA(e}Hgk^$$Jia?HF3-p5NtNSB!;0cIClWOa-TPQL7Sx?Gi zhuKgCZPd}xP=(xAes7Z!`j!arxIlsI>*Q3RZ;h=SoS2g!>M*)oLI4v`*0qS-9C5jUV<~#t)jH#2y9~ z!Bx5Z(1ofxf3pQZ(_$t!w45gIf)l-bYel|E1eu%SfB0D`>^ z2bxHnkkZ2ADQ0;U+Wx=T&)yhlmNLA8hTug4HWPo~7e~7MsssaQINfFQTK+;bM?y)7 ztQtak-s*9Wh{!UifH@&dvlq~}V~q~;;lI3zOatVeIOd_jEM&t*DPK#33+L6p^B|_) zQ|uc;6|Jd988*W?Is>{F_2$_F5n1pb?=2J{7x&Bjd$&i$mb^7g(t-XhzWwrk8jJ}? z4MG=?4VRWR^~?Y|qv51^u2#yo(Xus3#Z1A#KW2w>yNqs8w=$jJe0p+4k`Q<$W0R00cs2Eo z3P6ndyYGC_`FnkWraq7wpz|2_E9JrY<)Z;kJVE@Uqj&QI6!`0it1>jVh~bQbfNQ19 zK+F1HXErEQ7etjVL@*b^1faV$+K4n2C=fV~3}I?EK1Fp$*C-|IK8`Oi72>}@Qz$zv z7mwb6<#$_Ux+xI~@be+&fG7rL#?+Y#yrJMn+-#txz<{R?GAV@`CVR5CbXp)95T#Wm zVH~V`fdxLE0o9x%C7yZX-&Ss&6T9|j*w}2X6_vht{n#M!xaFV zGmbjJJSZFWtQK!Q)mIcd`X0uGA-rB)~ZsTGM92(@S_w$C(1CDE9!3G@vDeS#asnwqd_cC6%ok zd%CrvY%Aq5j^9%LJtOfO#E;V49;V-0=dl>$2Gw>`Jr8*4yxgs<`0FhDQ{f zO888nMM){Ms-r9*=V?W#{BrW=N&s>r0oSiEx@jWBO978r!wN9LhixyNKe zvvq^sMOu$LWr*pqYR`fPB*5;e^ zKX7sC-`k$x!<+K+)hhw5w7RKCw9$~IkxjHJMaa_C@x%z}&w+BbbZnpq8Lwr3SHze* z>H)t|RUVvI_B$0)OSK%wIff98y-L~XLayY<-#IM2NM?}poqIrX@d|V2#FE~O|4Y$TUG@nO#SsTjGj9XausX3223`bAyT*@1-?wJDxB7mL-6WTx?}rzIW0 zCgM;^X3#+qFChS)8*o_ppHnOV1IRvAq39la))auL0NP$IK`+SfjZf(d1E682Z$~>8 z^K|NkAq&^T+WPlS@O;U4J)O*=lK<2s=6QCC?@mO^D46*k%^h5~(F}CF3dCDdkp#7Y zi#zzj1!`PrH_lkdikX85x2RnVSJufza7~VZdUd1wrQbDe@mMSES@WBRbP{ zi)weOKfBI%w2rjG1gMvuM=iw-7d1L8WfHSe|iDMKO)|XpC1W8(O z;nu~^g6@9U?g)m#B7X0%*i9%KSLrre_uoY@tFzoGoocnKL<^NXB!0as0# z8a+WUdL~5kDZeA=n8!?PzneRF{yUhnLztPH2=-?B2eIGeL4mO&M2)=kXCJXRcK0;u z6}XtV_H5;?eUEMLa%auM`f8%P%Tn5S-L>bso?Z-|#jVQaqk{o_B@q}BP0od z)G$$Hh?#bS^1Hlkbhnb1bLa~O5yj0l{&D`~Uih}GVhcKxK)Hd+!V21R{vM?qNuUX+ zj?Z3=6h<$2mJ1nl%`fPNb_y4!{R`+^vr^IR3)YCevS1xyzQ%L5#Hipcp#h{%^vdrM_z2GGX+@EbmQ z{j#K_H;0^g6ChOuOMvc*O2rtG1otHZF5AUe80ME9AY_wSJx0TyXS+0KHyOdDpzbK! z80l{mQY<}}ayR8jO%&P&4VV`^hOe2jzQnzC1mduQS(<`UZFJjFR6~a2$?x)rhB;ne1U)?tyPpZE3jAEqMxC# z+W0LYz%sTH0Zwk!j*epZwy*8m(sE^4(`V8!^h!H6lkZ>IT1bZX#)yR6)^$!7RyJF5 zwZi;<01EbKS_g7gU9l>N4Fn@XyI}q5{`K@fuzg1M&qu>p6MJFIG1;dC%toAsG}LuIOS;C;C5c+mpz{!vG|Nvn>BR0u`B!gv>oN&6 z%D&&tA*on1x4YF#xdu3uRI4f|9wpy}a@CI;hZ7+4A3zypmQIkkgZtz_1$*X^sdZ6A z&k4!NcsTs0>}=xZ5mdbvVjmnap=xAy$|9=XxYEsHOi2}6VA{O337)6W58FAIgxq2b zEbx3gtk(04H@Jt023wEq0MlvKopwDF0^~qn&Q`I?B>?U4GfLp=dN;+St%}s?gjN+j z>smIot2(sN{5qf(1}5mHx>+yQNaqeHWj4`BX9#63SYMVoFd0~n&DhUCJ|v#u{M;z> zLAgY$Q3U-e`%BNvTQBJyEub7-Z9@IvuffCs3_R@qTuxTV6E32|5|=#X-#NF8QEYx%ZcWMiKoe~% zwbT6aSQa1!47QHV!C)!{HrFwtEPwUyyrG{K-J=(Vxu`@y39?X22Uem3BO>c4IGd<= z)RSUV6lAu{_K)9AN}Z29+{6jTvCo88FGnsW{Wlq>#$5iXwO_L4k;0c!g{D`*_w(5S ziwkZbYKjAJgmJ)uS*O1A{|zU;%6N)~i>-jq7HH_O5*mc&+`Rw={EfxNpdB$;?9xl9 z#Rg~F(tpeKn9EaJSug?(v)t;p1oGa_3;L-xg~LK~CUN-crTh{p`Y+A0Y$EBH0(eON zofia#7hCnvs?g|l@R(l1q6bDW%rIAv#?RngrWRVM!v6p7NNcMq+W*Q0{BnH(p%3n% zU(0B3S)DepfOh8Il}7!`SYHgCkG?|BPauAtKaL#)0^-|H&mWI(S$ACk3nw&7L;hMA z?py0}-6(w&uEnbB_uaqkDoLxLR)6y=yc*10|KMNdqY{{_0@Jh=t|-VJEp`LVDD0pM zkWEd4Zat(I%m9s*?X`$D(GwijIF9De-QHwq*_ z>i%ER)AksFy^>$hh9IrIc&?M_4Q%NYBJlUh$H10%71}A&w{-4Dza(#Eb}Fg0#{sZM z4PSBCpX*G;Ne&6j3oBXGlvPh7rH_BBOI?$Gn{Lq4d5Gx9u`MV0>q3rbV%f`OstZLs z4GO4{fP4&omTrMP^y6UFBCS_{jm$sAxGTt)w4U!Y>pgzvVrdASF5`<3SA&dX45s|G z?j45vSR_rRe`I}uKEq*Bf#3DeR=J?A0oVWg{RM+e3lWiJM260Js*e_&)dSAIHA!n)4e2M`dKF0JT5izMh{K6_AX1m#X<0#^Moja}wo}JPfwI*5ZtOnc!mGytP=@pXdl5f2~^$G@ErEsN;^T96VdSmEc>4$p!JJn zlC%$*xe)@!j|{>^lhA7)XTU%3{XIqggK43>&Kjd!ZW}Y{LB_>cugdM3VlY5e9L!~p zd;BkwzTnicaTUG^EG>=sW!5ey(8$8wr5$u3@xETnhBys5qP4SP^oJF+Z_x%ragXAF zS;6Yh|KHlm&zhH)hb1%!Yc>DeJo#$Jstw&cB!uta!giPwS1LctmZi4*k|)fRr- zJr8n$%gZBInr(KS6dV_|Bv1$GgpU}d2WxgPya#UVk&o<{dYNf)QBrEg&-B<=8@qu` zy_h##*@!@3Z0Fpet@mkPLcvdWap&h-4mjM8lYH~P=kQwpy(Xu;U+E?=A|mI470Vn^ z@uStv3;!HnG@&Fafki!(?Nhp2q`P|G!aXHP*LS8NrQN26&5x8UEG16NzjtEN9WjYY zcVJX3myYyb-m`R1iP!R-eh9@KjzIr4?o*d|W50OjQcGQf1J=O(g8pWk*Oko!ZpeQA zt|%AbjD!ZD$%}ryOIY#$apANVTMlU43za-ouXOI$tz5mQOYd5p`ih9e21llKUEDnX zHk3yuTxnP!d53Rh`=UKd?lFlW9nO;{R1g-U6BG1w%Yh?1OwOI1d$k5Q?fdLzp&B9z zw&*gZA9a0m_sqL32TVQ&G?m=}&O4O7(YlUw8jc|cFfhN#E5F%sV2`;($2W@?5^u|^ zw4NS)`E7#*!nYpNfF79ifLms+n1s$>rE@9eKAUa=&-nP1W`62N2 zF{5E8!1L68jtYM`aQ%GDOP1G&>atM;6pIznsS5RBQW(S@f7!W^{_jWk1tfV7lDr*xOpkV=<;(hWoB(Ea_n z&wH=F@3+=0V6o0RXP;eX$8S%Fijo}ueTw@iC@A>yucXybP|!iZ-y7Iiz)wymYz!0> zS`>Nd7aDGmom3nr6U~|ypT?1TVwD^5wZ-$ewL`JTYA>jVP@kl~)J|fcHJs(o&RD{u z6Tg>Y{sE1dDCI@sh7xhC6Ge$@P5-UrLZ^<>-jS zqY;)!#KFu{ghMf2`TqMce2kV^$D)EAjIEN@`YZT1-*cCwm*VBu*!LBx%SaK76wHxy zs06hCe)6RZJ?tl+e?+U|E>3e^rcvv-B)!NZO2}z3ii72l+hhE7J4y7VmwpBGT-nPs zS_@ubHca>Xmkb1Yac?!H|GL&sBELW-Jr}$mgb4%E?y}=P(wTb^Xh~@5=>!PI-Umx$ ze-5Yhdxnwq30K+h+o!&%gQ$W9u?Xv@_xszcSwIonbdikz5(!W%Nxw9*fi?= zZ2vdawn9T%A4@*RY3B!s9DR{K=ji_!?u&^w@ClrEU%_Q|f?Nfvog%1WxH^&z|1qhU z0{%lOl{9T+ol{b)$#|sC6MxsF32LH*U{*DV!Zu6ifBd2K6J$1gsrOvQurhl&9cAB# zsTKp%j8CyQtVh4&%^ykq48l+d1vlaQF)ccDlG>jM`m=@eq2}wdUFb4=*l-o@>GajaWb=8N3XCU6%kA(A^bgNefHvy~cK4k>@vvPCHId5+OEFpI!_h^A&r9Bv;UZ$B=~VgE5}^vcEhp3$4$yq%fd}iO|JKcpP@K; zo0V_y5FK=SL4UDci%!z-ZJq9<^HvMSZ=t&1;A>6AMeX1Vp*l_2tR)M`<(&8VUqlh1 z-tV7rF_r6T!CN?v6gU_cc-->5{K~9rvl8+E(ed1f=`TGR1NNSrDchc^aHK^(&uhr5 zE73|mD7r`WAy(a)Q3DX()1L}bf0U&}I%5>`PP|;c;|;(0t`$qLTHFn9hh_`e>j6@F zD$JPvSduv_S}Gg&-1?a|rdx#Zz=aL|eo??VB~=moumcMyWXX*8ueF?LsZ88+Qx#<) z-4&6}8?L&$_UDPl&Wr%fjP@%Eul}MzCPN*glafkAnYK&#H&xqCjRMR-zhYiFwU9jr z5E853d!4^FW<=ZJx+CHo4L((p)=ec)5*Q_Q6Qb*!qm7SX6g%lx__H;qOol#Yr>>B< zA4{Z5F?q`n_LU;~IgN;xb|FFqqSi6A@<(5O>RT=@3`lD|bLlkpoP_q~&64I2AwRaI+Tt)b%~@rZUy%TNkjv?`R`d zP$a*<>0c5M0qm}SFIRG}Z8a~{t18t!bw!5-BtK%t_2(Arv}i;LJ4q#H%$ED?_Yf_^ zFLYba)L22C(Y&;O$okx>1uT0@aQd>N43E=bDD`m^3WAYZV(^dIq7uCEmkh@3_mUha z!z(3QlMPY!MG~YxrT;?^3JwP&w{0`+h|BzLaJ~WgI!#q-zWS0nJv^4rJm-uv{gtA4FNKQm8vV@m<#v!Ut z;2VLHrCu*M&n0c^4}E@d*lt!`%}jJ%xp8wYQ^CIB2IMn7sXNlCWa-F}5dF2JKMbZd zMVlNxC_@qELh$0{!4#AC?0cdp9`t|E#a4mT5&wDTeKFLYe%7zO2~+VhX6qSXF0qCd zPW)b#;#<-}izD5|{KIEjzhBtE8|U?a&Q87rg)l@DeW3Lr)-9P4l zO7JRP(q;ByNb=aGfMnPcS;%%}$l{}U_*9sW4Ye1nq4q@Xj}0>zMo7BsOytoz#MEH8 zpeYxH(%^Bnj3~Lu&PJmk0zLfyFzZz-4jSNAhkQ31Q&`M4=*)-&B*^saJ4U3QwG*NC zhOx$k|7AD~kCG*a8V;fgmsTeHwz2XH%-y2;5H6$K&y^u{_x@%=3e(Rjmn$hYx0Y;~ z%`YB$T#nV^dA-Q7h;#zH6=eZUB4vm|w8@h{+{l+|oz5=zD6eqBMU|mnKwQzCOyeR$ zZ@HwCqi0x9U#kZ|CF;XVl39sJRg2Zy*wlfy*JAd%wf8?6>G(>raCMu6s&Hb)${73j z+4|)OX=cF=lO6{y1NjO z#sUP{XMQjC`ArIq+8Y%tlk++4WuJ7%469^m+M+xx7|pJZ zzQvfjvYrAC@YD?K@(TT{^^&>-`RrUo37D5Vh#(NElPb<<#4TUw?X2U^8hm&`7sH_Q zwa8^tFX*^n3>xXZfb(p3SyY>T`ZK9-T&fWHT-#kK|un0KuT6-clI)0@YvWu zOLULYP2SdLTrj>6LWFlrv!kOgxMiWGmqeW4gfOiQph?zW-O2vhgz4sw7Uj2Y{OCq# zWjOhka?wZPYwI_)ea`QLvqRpn)p3(`w@gWMj2r(ThS^NF_?@;guz_Yh@Faxj8=~$*FrwF99B+^$ z5!{enlVeB>&>k)qgb2)6ex|Sa3>oxc^kv^6%3!{S7};dbN8i=_?x9GYAi+G*`|?RcxvUK)wAIgD=3Eab9jHxW&W z-u3tyPiz!mdebOZ!GZm*_Yh0V96xN5fy2hXy$$yZ#L#ccwm;Oo<$uKH@@Zme{F z;_48U?A#u7PxMyA&3^B;(>7}n<-Xn{ywxb4Cu0R4a)0Eg`c-g&7D;s&58WT*Q=a7$ z<}1zKB2?P8MQV^uX~2mY>(P~A`RyAI@{urW9X*Q2WFpAQEgrGi zD9`l|sBC{uRxVOcSW`v51m_rWlB9v$k?o*tsLo3apoPKm08eI_B5t1{u0Uf%D|3 zf5Gy23Jd)u%SM<}M46H>^`SQmdPLWd#j8reM>FaD!?eB~%=cNk^fDJdv6m;=9Mkhl zX}UV|i1~6dsZSDW5t$tFHjcD*r6Z}Xx}eqMGH6Ki_juA7*RKekBmXUvc`?1KfE8`_ zfb;RU`W`RaY{I}qemqPdNV*RTQ99;+8O{M;;;NvgXYoZ{i~grQZ`Uak!_f-(TzlK2 z`gW(8frIB;X)qf+Ord*(9ZlQZHI%=%j(t*oZJ6O-RBkwIc(`b5NVrey3u^fkJPcX) zU?z_$>`4&Pf)kH|?jm&8C}FZJUO9BTXDEX-9t9fe!>H`G-pwwiccJ8FFH zk2*y+_z04g2_~SzJ>UjrP(cVd?7o8FnveYV zdqO$q&(;HldT?DF4=VRT+E|B+*NcNbBP6guj9hYPwDsQpn&y$>Afb?E#)4UX@%>)< z#fa+yNWfq$sk)**_h8A3dh_FsgxtbYhnoSE6MkaQ>nCY&=}Z&5@K5k2ULGX52YHsv zY7dxSX;toE#Z)d=J`Mhri;I(qds6&1tgz~#-Rs-KUQ*l7YkObTZw+L1s=Hxq=zXa- zoI9a)+brDpePBA}uus2^`LQ;J{2f?-Td@EPuHP#R#)xGEgHQ$3J-!XmsL3oTcQ5yZ zCEj#|(6!3PI~?>2^DGS_dcb=A14C;)eR?kPvxiIJb=98$O~W-%d0guqM{|``DZ2Nz zeznDG%*?xjH^HZ>W}OGEk%=oAmQ`zx?=6KKNu?Cm&9IAT%gV_MT>!_27!?}4N`t?d ze>s@m{MCMwKy9rj?6`@P)2}zv0@B+X3p2Ojl?Y`9iSQ~et#mxIx7Ral@VFidYptPK zzOQN^?mnZ7;+1&5XSV&xARljaoHV3 z!JAt%m)W!0j1z;+nGebu5(ea(u1gFp3pO1HJkg|RE3#k{tOBlYmIp+oMpPDig8+O- znua)ikrTyF@OICJiN`~wc&o~vs2F8Ym^)=|Lf3)CwL?;np0P4?aC1Q3x-VXVLx253zekMpy9rM_(sh4QtPfoD99A zSOwursYp$|kPq?+U+`fDY+aC!CocAP<<6lQk7zUmFS)L*yWiE*pmmrGb&=(v}IzrftcZKGKo=nKHTkD3&w(`e5<2i z@Mf=JCI8ZEuQ-^r$YS>f_#%(%sdFzl%LC-Vi#b^QC5%}n?U5rbf|02;H%}d+_nGv5 z|0)-tAIi9&yQ8+Snt6#I9|NyU5QOnF6_vrg1z#*AgTPULui@Ng%XBIcX`7v;e&3bl zExR}5p_4vzqlXF^PVwcMso=r0q_xfry(=1+N)Q z+a+B=Mzj`QRlVsSGyAM2R+(hhW~INM6{eTs9k+a!eE=xzL8Fe%R2en@epSbqo;F17 z+h#Dz75+~N^b!1pTbuKtY?N*5>=4|J{$KmZf{2bihIr6xKx2qrV|9I+d8`n=ZOLu) zJrkbdbI&(VKy-K?qc`kbhV1vA^A}KtXb(bGkSyi63U_aq2$Dc+Bm`}AcFjkPNXuJ6 z=*Tod#4Xl*piBWGNI{QPY1LI0WXPg~C*pXl9^ZCz%SS#fZ|^q;DJn1!`_Q2b)7ffA zR-XPGgp`k_qg?f=q2gT~USd>Yso~CB-Zb8Kzo}bSZ%T7s;1Yuj9a{bL%B%!ofKVR2 zj@TG_qrz3nO~r33+6%bfh#~E6t3pKdAw@d5kF4wb{+9G{%31W1oVg>ko;EviB2RZp zvu{iz9X0+v?h!&>eC+vX{P}4?^7ssO%V?Nf27H1^Poh~2Ivhd1Pcz5$Az`~={Y<#<2DUL;|kqJ7&QADYjo{ls+Vj zfx9xHBWWt~)7Br7NZqJwwg`J(1Z-R6((dU>mHd~UMPPI*DDORY-XdP~ z!W#5q0TtA&oB~WfVyNfffaOoETpFq5F9x!=?p5&&OUq}yq~$=9J(`% zr}9|drc(AZMv?m%ojuIEp-V>vnO8j##k2DEs5c42Ni7!U9mf&Hj88ZKB2+|0w z(B}|oZ4IW^S_<_L{JxWYgM=Ph&k(hD1sXe57T*(njI;9cf5k`n6-$O-7I8s=I3K&XXOW?O>Ab!z7TiAi zaRS@IW8KaYGO1ZT{8R93j zfy6ZiI2_o2F&YZWflqt|L*=7N>-%?Txglk@kFn{lg$3nodtTiVX}0jbz37ag$A7T? zR-{=L^E;Ll1rFoSC?))p7P|_=JL37s4}_@7ujB^&6F{J*o1RCW)swzq1M2Iwg@_Sv zK_ejo$Go}tTS597b<~fiHBMrppk)s%31>GSx%=L|oaeReC1+B~ujUtg{PvA}wVyMzQ|Kn2#}u=-&w12w3W z3fHc2sg|szso#M+E=^E(mPDxf`o7qr!De1>hh6SxO0jHX7~4uYzXRxtItEP|MYoS( zCJ&(T3ramER|UkEx7-i`Im5n$1h6 z>Z9F|Mq%3{_Rz_Rk#IeV;n2I}{2{7a3-}4@6+OLxkL6OOUCvwWq4l$l`%j|H@Np3& z-NAre`h2+bS^Zjv;uB4dr%V+W<+Zo{k{KjuFyg4G!qsqmR_}FR#94xc<|Tfu26LE! zqmBCRn}-O->jxpdIDpnw*=Q^wuJ%>q7U@}2FI6D@H9z{!$80f(+hA;j9YTpkUjVvH zv+Y?y5d>f#Pj;L;Z-HLEU~n)>$LimL>*Qvcb+l7$nLv-3y-bSp;VW*9wLyTSQZj{} zxW}{8ZQ@_M+0^8)HTUz=?j+-dFp{NwIE1&nkWDeuQ|a%ncqF7Krgrp8J~=b*N0 zk=w=t^@iqb>O~7i&-R1}SZOC#S6`&nwO>Sc=TxaK-v0nI?{r->{pyHeHhYGdRaHwReEhhhpaxHa zsB``;iCw0rzjdz+%?CFfDWK0qehzNh5cJ628X|1mlj7HRcztlRM=)2!KXFSHi^;(N zT8v0*Vv6k#K}>jo#>OmIxe*-%6IkDaZjDOjS`J4eQk$*CozLk}wm{_89}1>}#?R7V zX2S!?YlsK4`0EY7-?Y~I?c(;zSmn9G*E-b3=|0vlgMgq1i1S643M>-;a8t3Ts8m20^qgIX}w_He<@sa zoJW=di^#$D3I0WQQQ9lLLCD1BaM11D<$O@dLzp`EWcmLj^CAN>DTDoWh)~I`?!Rtm9Rj-fZs!%riFH_QZ>PLi8uV_=UOYorNy<*;yYUvS_3cZmyI)@DM;q z&%^8y=#`t`@jxcC6YOgHE4N1(6Lt?L!Nj;2g` zI7F~;hVhhzXiH|L3WzGmP!zdc`*M~{jR$MC=L}`jLv>$2 zt!k)k4eYwoTd{WoKN&nTJa?b=O7WR|v$uc1=Kf{phx1afaz#Cz`uB-noms=ogkP(v zmo0$A{fWhuZ|EsjY)6y;VRzqo9=e+=pJQyAdf$53sOaF(3Gsy{LWg}z=}a8HD~3B1 zn{+kNtAkPmQUe^omj;rj#U-(%kFWB1Rq+hQ`jy);Shz2`rnU#pbG8U&?HKj%&7(Wg zd_G_gxQx<8ZIt>6MOJdGwJ@O9?VC3!KDz#5Y4&ThU-O|->5UbEq~6dAsC}i>DF)v~ zwXCuM^`z@QA=*weRjZOxsbz4M+hjsLm-E`yG6r^8?6-sZr$8i8@mM%q9$F#evQhVK z1#`jF7(7}llof!vddkMDmr7ROMm@Vi?~%Q1K)fK(^InNHdkK~0s@D%H;-jZ$`1a6l z)AN7zJ}=g%Z#Z^_P{~U&Pi(Z)FH$sfDXAR7y~u zkp>SG(WzHog9BzLoiH1hR*P=P8B06l5L0iaw_^a4j2o1tTt0qqdq3-z+{Wp9w)msC zurY`WkpI`wbTYk(gI!|O^}NZ%LT6p^cme%UAk;#sH_we$EZk0n2E zjj`>-9w(lZNyKCG!+?(x+cys-qlVA$XM~1RtB{7_jhY`qSd_RMN;%u6E>p5^YLJhAa?%=%I7{@ptcZG^(nRE3K=Mhp4%(8ag)N=aZ@ zn|b`>9+CBBl+bhUW|T79?}4`dw4Q|%<`h=CXZk1}q|^vZWS*8A+DQ9PrzUf=>LOh( zpY>b;A0_nSk`z&Bh_aUST&aL4X4YOfjg8XBdjX{j1>n@?ZTNU^o`Ujt`yOmK3Wb?< zp8;i$m%^CARlfGtuCFik@S(gsri*)mQ z%(JvH2y>(qgPg>)F7Jn5X{jC-Ax0ufPg_mA<{A!grR}SG5?H&ElA5|qSy}{3o^LjH z7mKW=v$lp<5Sy`;q0M6(d?FVuKm1)`z+_&SPiD^o;#sgsr050bh&Wb|_Fv7N4ItTQ zaG2!gTHeG@&MkuS*OQmZRu-|*W;E-4*7Bf@qE2jx9Gpu0lq7-2Uej^bcJCx*tZ#BQ z^MJ<}etd&+MWl`JBUf|07o@)9eU;GXn zd7swX^uzgYoYfRqVJTcR>$IF%%XAfnLLG1Bhvg{2(%QjC-XuKG)zqmE1SMQA47DIk zvf{=YJRJ|Qes7K*gt6~I@<-C%(;$zgDoqy&JHx%D55D!99B`Dmo-LCo#hF>zZ1r^# ze1>t}D&d3T-3o_Shwx9@faL#k$C>9;f?^%`$L=dA>hsOYik(_XN}PV1q{M?FN-kGq zbK%$Kk-7DYFbsDSwR4UY9F1T9G;p7~NIl)^1(W6clXyI<&HTj4^G2v}DSXppBkK!` zYa<;i=s@I~CS*TF`zB0i`5M)Ck@TWx1E{d91hNgBT&(3UI3H;9Ft-~3L3+>PEf8Sw zxgkd|^!t#NhgA=u$fHWr`Fs2QOod^hGSaR^3UTcc&);4G?-a!pBf9HXxD()dY#6x< zTgE7^HaT;WVKWO<+!^n8CK%Zq-1;~*zdmQ@y0=p1ka;45Fg@u~F<5lSuihGP@F7^= zh<7M3rB;%JhTPUnGJzIvcByW-rRPZ{HdC;Z(aN?g>gs;pIY9a^8rx&N$R)*8%{r`ho^=!<~`tM~VJusBj{?>+Dt%hG#+Cr!>wS28l< z)yMZT`b^a!ORn!dpl#HFU2fz!pyeq>@0Dkd#i`@M_U3MU4jgXIxo>|(OJE|FzV&?u z80SO^>NaQEj;e?+6PCGg$RTwu834+O# zyb0n)g`ORDqg-DhH<3^chxz^2`K8~6gZB&c?yAlu6-_BL&3Z`mAV)>a-<#^SbC)Br zA}izq9G3#V;XR@vH-tpsFaVCCi@?QYhUWHiyX z8kvMrISr+Jg~$|}=cOTTcizZ;um?x{nwjK=y+(=f3a z*Z44BgST{MbC^I-RLuh{?Kr(+&Pw>lj=k#liw5s437X{{l}SmBtC@y}QN-AJ?1uwn zmpk!fp&>Yr9!l6)2h&_H_JG~cjE_devX#>yL2?DXpl~d#b`Sl?BBk(tXa*LdgCFtb z(RCR{Zi^fMtb_kK}_P(z9Rr;Q@+CT z6~wG(M}4BlgVwP$u!W9!E&&_I4zI!g`iuL9;YZ=lp#92|t)zmo1!<-@I<-7_ftl5a+%|}|Gri{kG zE(HV%#KoLszwWXM`_QT!bwj!u0K8?u>Kos;Oi%pi-RB~-Q6P+ZTMVNVNBgO4bV%7W zeSUbj5Pd2K z>`fRKhT9DKv5SA{w&7Gy)?CC8|Ilv20Q<7PDgcS-1j*|ax5dFUzTH}nM8?4$c*`Rq zMFvK`n{L~f-|zRg_K+Q?Dfs!teJH~UjS#4wd^6qqPCZW54_EdT3VO&~b*lIZRLL^; zHjyKUS+T&I1$1yQ9uFIkc+lQOnNBSc)`VT??$ktD3}i;suH$rJ>ml(WBkyBN!;mrj z2f>HVfCD@Ov(>uHZ*h8((dEv|oB8mm8?JKi)|+jcm-Jwsv?UKM#Iz7#y@-1lgIDy&@7ToS(peN>a{!eM%SFDEll>A9_7i;-iRdKMkOG^D^%85?g3xTR!;!7DK@=z zn|@#Xu|#Yjw*U8l5TjYjDQUP2#uNDk&*i z-o{W7JwtcdE`y@hl)L~6 z6M4~-|CG*7zeZ2L9(+KYkXB>dRq)D4`D67+Bb9$HC-)e_^zzuu1NU-V`T@bhPZICf zlY7A9MlNq18R!7)9BZHkX&;3(FA{1V_n%EF$lmn~I4;^*dc)-B20PqU_%4u}K_ki| z#Xz5d+RFo(Maefs4B6e8$paoC=Ox^SfMcEWAfF4Egrq+pOaP#zcD|disiKf%?bzHq zV8yghna5DPhrEp$_Gk3(YhP;AjAfjmrjU=kn%eSK%*#8TC*sRw=Df* zrT_z- z4n5m~(@L6RXj|~98iPpyI+?aS=uzVNx<*Gth4(p1{Pm2FQKBA2oLu`Ql;9S)JAD)b zsOq#CkESf^eaDbFt$%N3+e$YvSf=va$6cTYZsKk<@bF7v+nB`Of*j*KlYqYJ8 zl4;o`H3&tfsS5|9gF~4#d(v|~RDH|F(XXeJ%7a~c&?U4!$7tjc88gsVO%wA9gpJc) zzNn%3+{AGZNqBMtgg8F-X#65D_ZUj@D^{O=b)-HR0T*QnpqxO-_RJ~ z14(}zH5qjk*TgR`VcW(P@$dG@59FX06#CmCCo7oa08*s60@uX%4F+8NmQdx&pqRFV zzg`iOsnPZdgN48++$*U>S;sIX4)zRxqGIJA0I%d%g8_SRTNSQzMIg%Y%t!uM5sRNW2D`an^=pRhMn?7Oat0!dU5AsHO$HrP`f>xqS(@l)>TH{^lb)m6BzU4LmFR#xPHO`)ol z*BIQ}{PIx3X*6m7*HHaKz~-z%nqo;2VxWy0VxWFFiUt?!AQ(}cWkQPuAbDjjGU%LHw2%p9e=@ zN}J~MKI5fu{_Ua+0dlHe;`}_jE+__gqSw5(9JA9uu72dIKnSG7K+%8$GBvhvuK7P9 zJi?X9F6<8ME()I+8aVyVIJ3flk?V{wZ8Wp4uU@;B7x#YaXQ$QIaZNZ!xF|-p7&02~ zg512T@`|VN97l}{!3heT4LZs4T4fQ8{RqUBh?<-7@KAt5MJDqB@Dgl?|5&?G*U%c0 zEwyJkf-#N}l%-$mPBg)fgqa;Y) z>Ec=3sG|lGd|$thNv^soTsQAu)_=>%g#Tck3x_+M;B zeD+Ynn87X2W)ea&yPdNhy0iR4J$06!xvkYvaBB+%f4i^Oaw=Tnf*aRBsH+SohvHPZ zdws8!I^H;7|Mcj^L9Air*%dbZ8eS#F#ez!gmv-<3JLuo0D=Him>c}mA#>nxDF)pZ9tElBo)t%RCH)_jzFYlXjs>Y}Jho_;9iY6YY{25EfF z;23HJCt7-+fr)v)^tYLg;TNEj=6W{ysL531kq^cP&tIoXUon670cA(e}Hgk^$$Jia?HF3-p5NtNSB!;0cIClWOa-TPQL7Sx?Gi zhuKgCZPd}xP=(xAes7Z!`j!arxIlsI>*Q3RZ;h=SoS2g!>M*)oLI4v`*0qS-9C5jUV<~#t)jH#2y9~ z!Bx5Z(1ofxf3pQZ(_$t!w45gIf)l-bYel|E1eu%SfB0D`>^ z2bxHnkkZ2ADQ0;U+Wx=T&)yhlmNLA8hTug4HWPo~7e~7MsssaQINfFQTK+;bM?y)7 ztQtak-s*9Wh{!UifH@&dvlq~}V~q~;;lI3zOatVeIOd_jEM&t*DPK#33+L6p^B|_) zQ|uc;6|Jd988*W?Is>{F_2$_F5n1pb?=2J{7x&Bjd$&i$mb^7g(t-XhzWwrk8jJ}? z4MG=?4VRWR^~?Y|qv51^u2#yo(Xus3#Z1A#KW2w>yNqs8w=$jJe0p+4k`Q<$W0R00cs2Eo z3P6ndyYGC_`FnkWraq7wpz|2_E9JrY<)Z;kJVE@Uqj&QI6!`0it1>jVh~bQbfNQ19 zK+F1HXErEQ7etjVL@*b^1faV$+K4n2C=fV~3}I?EK1Fp$*C-|IK8`Oi72>}@Qz$zv z7mwb6<#$_Ux+xI~@be+&fG7rL#?+Y#yrJMn+-#txz<{R?GAV@`CVR5CbXp)95T#Wm zVH~V`fdxLE0o9x%C7yZX-&Ss&6T9|j*w}2X6_vht{n#M!xaFV zGmbjJJSZFWtQK!Q)mIcd`X0uGA-rB)~ZsTGM92(@S_w$C(1CDE9!3G@vDeS#asnwqd_cC6%ok zd%CrvY%Aq5j^9%LJtOfO#E;V49;V-0=dl>$2Gw>`Jr8*4yxgs<`0FhDQ{f zO888nMM){Ms-r9*=V?W#{BrW=N&s>r0oSiEx@jWBO978r!wN9LhixyNKe zvvq^sMOu$LWr*pqYR`fPB*5;e^ zKX7sC-`k$x!<+K+)hhw5w7RKCw9$~IkxjHJMaa_C@x%z}&w+BbbZnpq8Lwr3SHze* z>H)t|RUVvI_B$0)OSK%wIff98y-L~XLayY<-#IM2NM?}poqIrX@d|V2#FE~O|4Y$TUG@nO#SsTjGj9XausX3223`bAyT*@1-?wJDxB7mL-6WTx?}rzIW0 zCgM;^X3#+qFChS)8*o_ppHnOV1IRvAq39la))auL0NP$IK`+SfjZf(d1E682Z$~>8 z^K|NkAq&^T+WPlS@O;U4J)O*=lK<2s=6QCC?@mO^D46*k%^h5~(F}CF3dCDdkp#7Y zi#zzj1!`PrH_lkdikX85x2RnVSJufza7~VZdUd1wrQbDe@mMSES@WBRbP{ zi)weOKfBI%w2rjG1gMvuM=iw-7d1L8WfHSe|iDMKO)|XpC1W8(O z;nu~^g6@9U?g)m#B7X0%*i9%KSLrre_uoY@tFzoGoocnKL<^NXB!0as0# z8a+WUdL~5kDZeA=n8!?PzneRF{yUhnLztPH2=-?B2eIGeL4mO&M2)=kXCJXRcK0;u z6}XtV_H5;?eUEMLa%auM`f8%P%Tn5S-L>bso?Z-|#jVQaqk{o_B@q}BP0od z)G$$Hh?#bS^1Hlkbhnb1bLa~O5yj0l{&D`~Uih}GVhcKxK)Hd+!V21R{vM?qNuUX+ zj?Z3=6h<$2mJ1nl%`fPNb_y4!{R`+^vr^IR3)YCevS1xyzQ%L5#Hipcp#h{%^vdrM_z2GGX+@EbmQ z{j#K_H;0^g6ChOuOMvc*O2rtG1otHZF5AUe80ME9AY_wSJx0TyXS+0KHyOdDpzbK! z80l{mQY<}}ayR8jO%&P&4VV`^hOe2jzQnzC1mduQS(<`UZFJjFR6~a2$?x)rhB;ne1U)?tyPpZE3jAEqMxC# z+W0LYz%sTH0Zwk!j*epZwy*8m(sE^4(`V8!^h!H6lkZ>IT1bZX#)yR6)^$!7RyJF5 zwZi;<01EbKS_g7gU9l>N4Fn@XyI}q5{`K@fuzg1M&qu>p6MJFIG1;dC%toAsG}LuIOS;C;C5c+mpz{!vG|Nvn>BR0u`B!gv>oN&6 z%D&&tA*on1x4YF#xdu3uRI4f|9wpy}a@CI;hZ7+4A3zypmQIkkgZtz_1$*X^sdZ6A z&k4!NcsTs0>}=xZ5mdbvVjmnap=xAy$|9=XxYEsHOi2}6VA{O337)6W58FAIgxq2b zEbx3gtk(04H@Jt023wEq0MlvKopwDF0^~qn&Q`I?B>?U4GfLp=dN;+St%}s?gjN+j z>smIot2(sN{5qf(1}5mHx>+yQNaqeHWj4`BX9#63SYMVoFd0~n&DhUCJ|v#u{M;z> zLAgY$Q3U-e`%BNvTQBJyEub7-Z9@IvuffCs3_R@qTuxTV6E32|5|=#X-#NF8QEYx%ZcWMiKoe~% zwbT6aSQa1!47QHV!C)!{HrFwtEPwUyyrG{K-J=(Vxu`@y39?X22Uem3BO>c4IGd<= z)RSUV6lAu{_K)9AN}Z29+{6jTvCo88FGnsW{Wlq>#$5iXwO_L4k;0c!g{D`*_w(5S ziwkZbYKjAJgmJ)uS*O1A{|zU;%6N)~i>-jq7HH_O5*mc&+`Rw={EfxNpdB$;?9xl9 z#Rg~F(tpeKn9EaJSug?(v)t;p1oGa_3;L-xg~LK~CUN-crTh{p`Y+A0Y$EBH0(eON zofia#7hCnvs?g|l@R(l1q6bDW%rIAv#?RngrWRVM!v6p7NNcMq+W*Q0{BnH(p%3n% zU(0B3S)DepfOh8Il}7!`SYHgCkG?|BPauAtKaL#)0^-|H&mWI(S$ACk3nw&7L;hMA z?py0}-6(w&uEnbB_uaqkDoLxLR)6y=yc*10|KMNdqY{{_0@Jh=t|-VJEp`LVDD0pM zkWEd4Zat(I%m9s*?X`$D(GwijIF9De-QHwq*_ z>i%ER)AksFy^>$hh9IrIc&?M_4Q%NYBJlUh$H10%71}A&w{-4Dza(#Eb}Fg0#{sZM z4PSBCpX*G;Ne&6j3oBXGlvPh7rH_BBOI?$Gn{Lq4d5Gx9u`MV0>q3rbV%f`OstZLs z4GO4{fP4&omTrMP^y6UFBCS_{jm$sAxGTt)w4U!Y>pgzvVrdASF5`<3SA&dX45s|G z?j45vSR_rRe`I}uKEq*Bf#3DeR=J?A0oVWg{RM+e3lWiJM260Js*e_&)dSAIHA!n)4e2M`dKF0JT5izMh{K6_AX1m#X<0#^Moja}wo}JPfwI*5ZtOnc!mGytP=@pXdl5f2~^$G@ErEsN;^T96VdSmEc>4$p!JJn zlC%$*xe)@!j|{>^lhA7)XTU%3{XIqggK43>&Kjd!ZW}Y{LB_>cugdM3VlY5e9L!~p zd;BkwzTnicaTUG^EG>=sW!5ey(8$8wr5$u3@xETnhBys5qP4SP^oJF+Z_x%ragXAF zS;6Yh|KHlm&zhH)hb1%!Yc>DeJo#$Jstw&cB!uta!giPwS1LctmZi4*k|)fRr- zJr8n$%gZBInr(KS6dV_|Bv1$GgpU}d2WxgPya#UVk&o<{dYNf)QBrEg&-B<=8@qu` zy_h##*@!@3Z0Fpet@mkPLcvdWap&h-4mjM8lYH~P=kQwpy(Xu;U+E?=A|mI470Vn^ z@uStv3;!HnG@&Fafki!(?Nhp2q`P|G!aXHP*LS8NrQN26&5x8UEG16NzjtEN9WjYY zcVJX3myYyb-m`R1iP!R-eh9@KjzIr4?o*d|W50OjQcGQf1J=O(g8pWk*Oko!ZpeQA zt|%AbjD!ZD$%}ryOIY#$apANVTMlU43za-ouXOI$tz5mQOYd5p`ih9e21llKUEDnX zHk3yuTxnP!d53Rh`=UKd?lFlW9nO;{R1g-U6BG1w%Yh?1OwOI1d$k5Q?fdLzp&B9z zw&*gZA9a0m_sqL32TVQ&G?m=}&O4O7(YlUw8jc|cFfhN#E5F%sV2`;($2W@?5^u|^ zw4NS)`E7#*!nYpNfF79ifLms+n1s$>rE@9eKAUa=&-nP1W`62N2 zF{5E8!1L68jtYM`aQ%GDOP1G&>atM;6pIznsS5RBQW(S@f7!W^{_jWk1tfV7lDr*xOpkV=<;(hWoB(Ea_n z&wH=F@3+=0V6o0RXP;eX$8S%Fijo}ueTw@iC@A>yucXybP|!iZ-y7Iiz)wymYz!0> zS`>Nd7aDGmom3nr6U~|ypT?1TVwD^5wZ-$ewL`JTYA>jVP@kl~)J|fcHJs(o&RD{u z6Tg>Y{sE1dDCI@sh7xhC6Ge$@P5-UrLZ^<>-jS zqY;)!#KFu{ghMf2`TqMce2kV^$D)EAjIEN@`YZT1-*cCwm*VBu*!LBx%SaK76wHxy zs06hCe)6RZJ?tl+e?+U|E>3e^rcvv-B)!NZO2}z3ii72l+hhE7J4y7VmwpBGT-nPs zS_@ubHca>Xmkb1Yac?!H|GL&sBELW-Jr}$mgb4%E?y}=P(wTb^Xh~@5=>!PI-Umx$ ze-5Yhdxnwq30K+h+o!&%gQ$W9u?Xv@_xszcSwIonbdikz5(!W%Nxw9*fi?= zZ2vdawn9T%A4@*RY3B!s9DR{K=ji_!?u&^w@ClrEU%_Q|f?Nfvog%1WxH^&z|1qhU z0{%lOl{9T+ol{b)$#|sC6MxsF32LH*U{*DV!Zu6ifBd2K6J$1gsrOvQurhl&9cAB# zsTKp%j8CyQtVh4&%^ykq48l+d1vlaQF)ccDlG>jM`m=@eq2}wdUFb4=*l-o@>GajaWb=8N3XCU6%kA(A^bgNefHvy~cK4k>@vvPCHId5+OEFpI!_h^A&r9Bv;UZ$B=~VgE5}^vcEhp3$4$yq%fd}iO|JKcpP@K; zo0V_y5FK=SL4UDci%!z-ZJq9<^HvMSZ=t&1;A>6AMeX1Vp*l_2tR)M`<(&8VUqlh1 z-tV7rF_r6T!CN?v6gU_cc-->5{K~9rvl8+E(ed1f=`TGR1NNSrDchc^aHK^(&uhr5 zE73|mD7r`WAy(a)Q3DX()1L}bf0U&}I%5>`PP|;c;|;(0t`$qLTHFn9hh_`e>j6@F zD$JPvSduv_S}Gg&-1?a|rdx#Zz=aL|eo??VB~=moumcMyWXX*8ueF?LsZ88+Qx#<) z-4&6}8?L&$_UDPl&Wr%fjP@%Eul}MzCPN*glafkAnYK&#H&xqCjRMR-zhYiFwU9jr z5E853d!4^FW<=ZJx+CHo4L((p)=ec)5*Q_Q6Qb*!qm7SX6g%lx__H;qOol#Yr>>B< zA4{Z5F?q`n_LU;~IgN;xb|FFqqSi6A@<(5O>RT=@3`lD|bLlkpoP_q~&64I2AwRaI+Tt)b%~@rZUy%TNkjv?`R`d zP$a*<>0c5M0qm}SFIRG}Z8a~{t18t!bw!5-BtK%t_2(Arv}i;LJ4q#H%$ED?_Yf_^ zFLYba)L22C(Y&;O$okx>1uT0@aQd>N43E=bDD`m^3WAYZV(^dIq7uCEmkh@3_mUha z!z(3QlMPY!MG~YxrT;?^3JwP&w{0`+h|BzLaJ~WgI!#q-zWS0nJv^4rJm-uv{gtA4FNKQm8vV@m<#v!Ut z;2VLHrCu*M&n0c^4}E@d*lt!`%}jJ%xp8wYQ^CIB2IMn7sXNlCWa-F}5dF2JKMbZd zMVlNxC_@qELh$0{!4#AC?0cdp9`t|E#a4mT5&wDTeKFLYe%7zO2~+VhX6qSXF0qCd zPW)b#;#<-}izD5|{KIEjzhBtE8|U?a&Q87rg)l@DeW3Lr)-9P4l zO7JRP(q;ByNb=aGfMnPcS;%%}$l{}U_*9sW4Ye1nq4q@Xj}0>zMo7BsOytoz#MEH8 zpeYxH(%^Bnj3~Lu&PJmk0zLfyFzZz-4jSNAhkQ31Q&`M4=*)-&B*^saJ4U3QwG*NC zhOx$k|7AD~kCG*a8V;fgmsTeHwz2XH%-y2;5H6$K&y^u{_x@%=3e(Rjmn$hYx0Y;~ z%`YB$T#nV^dA-Q7h;#zH6=eZUB4vm|w8@h{+{l+|oz5=zD6eqBMU|mnKwQzCOyeR$ zZ@HwCqi0x9U#kZ|CF;XVl39sJRg2Zy*wlfy*JAd%wf8?6>G(>raCMu6s&Hb)${73j z+4|)OX=cF=lO6{y1NjO z#sUP{XMQjC`ArIq+8Y%tlk++4WuJ7%469^m+M+xx7|pJZ zzQvfjvYrAC@YD?K@(TT{^^&>-`RrUo37D5Vh#(NElPb<<#4TUw?X2U^8hm&`7sH_Q zwa8^tFX*^n3>xXZfb(p3SyY>T`ZK9-T&fWHT-#kK|un0KuT6-clI)0@YvWu zOLULYP2SdLTrj>6LWFlrv!kOgxMiWGmqeW4gfOiQph?zW-O2vhgz4sw7Uj2Y{OCq# zWjOhka?wZPYwI_)ea`QLvqRpn)p3(`w@gWMj2r(ThS^NF_?@;guz_Yh@Faxj8=~$*FrwF99B+^$ z5!{enlVeB>&>k)qgb2)6ex|Sa3>oxc^kv^6%3!{S7};dbN8i=_?x9GYAi+G*`|?RcxvUK)wAIgD=3Eab9jHxW&W z-u3tyPiz!mdebOZ!GZm*_Yh0V96xN5fy2hXy$$yZ#L#ccwm;Oo<$uKH@@Zme{F z;_48U?A#u7PxMyA&3^B;(>7}n<-Xn{ywxb4Cu0R4a)0Eg`c-g&7D;s&58WT*Q=a7$ z<}1zKB2?P8MQV^uX~2mY>(P~A`RyAI@{urW9X*Q2WFpAQEgrGi zD9`l|sBC{uRxVOcSW`v51m_rWlB9v$k?o*tsLo3apoPKm08eI_B5t1{u0Uf%D|3 zf5Gy23Jd)u%SM<}M46H>^`SQmdPLWd#j8reM>FaD!?eB~%=cNk^fDJdv6m;=9Mkhl zX}UV|i1~6dsZSDW5t$tFHjcD*r6Z}Xx}eqMGH6Ki_juA7*RKekBmXUvc`?1KfE8`_ zfb;RU`W`RaY{I}qemqPdNV*RTQ99;+8O{M;;;NvgXYoZ{i~grQZ`Uak!_f-(TzlK2 z`gW(8frIB;X)qf+Ord*(9ZlQZHI%=%j(t*oZJ6O-RBkwIc(`b5NVrey3u^fkJPcX) zU?z_$>`4&Pf)kH|?jm&8C}FZJUO9BTXDEX-9t9fe!>H`G-pwwiccJ8FFH zk2*y+_z04g2_~SzJ>UjrP(cVd?7o8FnveYV zdqO$q&(;HldT?DF4=VRT+E|B+*NcNbBP6guj9hYPwDsQpn&y$>Afb?E#)4UX@%>)< z#fa+yNWfq$sk)**_h8A3dh_FsgxtbYhnoSE6MkaQ>nCY&=}Z&5@K5k2ULGX52YHsv zY7dxSX;toE#Z)d=J`Mhri;I(qds6&1tgz~#-Rs-KUQ*l7YkObTZw+L1s=Hxq=zXa- zoI9a)+brDpePBA}uus2^`LQ;J{2f?-Td@EPuHP#R#)xGEgHQ$3J-!XmsL3oTcQ5yZ zCEj#|(6!3PI~?>2^DGS_dcb=A14C;)eR?kPvxiIJb=98$O~W-%d0guqM{|``DZ2Nz zeznDG%*?xjH^HZ>W}OGEk%=oAmQ`zx?=6KKNu?Cm&9IAT%gV_MT>!_27!?}4N`t?d ze>s@m{MCMwKy9rj?6`@P)2}zv0@B+X3p2Ojl?Y`9iSQ~et#mxIx7Ral@VFidYptPK zzOQN^?mnZ7;+1&5XSV&xARljaoHV3 z!JAt%m)W!0j1z;+nGebu5(ea(u1gFp3pO1HJkg|RE3#k{tOBlYmIp+oMpPDig8+O- znua)ikrTyF@OICJiN`~wc&o~vs2F8Ym^)=|Lf3)CwL?;np0P4?aC1Q3x-VXVLx253zekMpy9rM_(sh4QtPfoD99A zSOwursYp$|kPq?+U+`fDY+aC!CocAP<<6lQk7zUmFS)L*yWiE*pmmrGb&=(v}IzrftcZKGKo=nKHTkD3&w(`e5<2i z@Mf=JCI8ZEuQ-^r$YS>f_#%(%sdFzl%LC-Vi#b^QC5%}n?U5rbf|02;H%}d+_nGv5 z|0)-tAIi9&yQ8+Snt6#I9|NyU5QOnF6_vrg1z#*AgTPULui@Ng%XBIcX`7v;e&3bl zExR}5p_4vzqlXF^PVwcMso=r0q_xfry(=1+N)Q z+a+B=Mzj`QRlVsSGyAM2R+(hhW~INM6{eTs9k+a!eE=xzL8Fe%R2en@epSbqo;F17 z+h#Dz75+~N^b!1pTbuKtY?N*5>=4|J{$KmZf{2bihIr6xKx2qrV|9I+d8`n=ZOLu) zJrkbdbI&(VKy-K?qc`kbhV1vA^A}KtXb(bGkSyi63U_aq2$Dc+Bm`}AcFjkPNXuJ6 z=*Tod#4Xl*piBWGNI{QPY1LI0WXPg~C*pXl9^ZCz%SS#fZ|^q;DJn1!`_Q2b)7ffA zR-XPGgp`k_qg?f=q2gT~USd>Yso~CB-Zb8Kzo}bSZ%T7s;1Yuj9a{bL%B%!ofKVR2 zj@TG_qrz3nO~r33+6%bfh#~E6t3pKdAw@d5kF4wb{+9G{%31W1oVg>ko;EviB2RZp zvu{iz9X0+v?h!&>eC+vX{P}4?^7ssO%V?Nf27H1^Poh~2Ivhd1Pcz5$Az`~={Y<#<2DUL;|kqJ7&QADYjo{ls+Vj zfx9xHBWWt~)7Br7NZqJwwg`J(1Z-R6((dU>mHd~UMPPI*DDORY-XdP~ z!W#5q0TtA&oB~WfVyNfffaOoETpFq5F9x!=?p5&&OUq}yq~$=9J(`% zr}9|drc(AZMv?m%ojuIEp-V>vnO8j##k2DEs5c42Ni7!U9mf&Hj88ZKB2+|0w z(B}|oZ4IW^S_<_L{JxWYgM=Ph&k(hD1sXe57T*(njI;9cf5k`n6-$O-7I8s=I3K&XXOW?O>Ab!z7TiAi zaRS@IW8KaYGO1ZT{8R93j zfy6ZiI2_o2F&YZWflqt|L*=7N>-%?Txglk@kFn{lg$3nodtTiVX}0jbz37ag$A7T? zR-{=L^E;Ll1rFoSC?))p7P|_=JL37s4}_@7ujB^&6F{J*o1RCW)swzq1M2Iwg@_Sv zK_ejo$Go}tTS597b<~fiHBMrppk)s%31>GSx%=L|oaeReC1+B~ujUtg{PvA}wVyMzQ|Kn2#}u=-&w12w3W z3fHc2sg|szso#M+E=^E(mPDxf`o7qr!De1>hh6SxO0jHX7~4uYzXRxtItEP|MYoS( zCJ&(T3ramER|UkEx7-i`Im5n$1h6 z>Z9F|Mq%3{_Rz_Rk#IeV;n2I}{2{7a3-}4@6+OLxkL6OOUCvwWq4l$l`%j|H@Np3& z-NAre`h2+bS^Zjv;uB4dr%V+W<+Zo{k{KjuFyg4G!qsqmR_}FR#94xc<|Tfu26LE! zqmBCRn}-O->jxpdIDpnw*=Q^wuJ%>q7U@}2FI6D@H9z{!$80f(+hA;j9YTpkUjVvH zv+Y?y5d>f#Pj;L;Z-HLEU~n)>$LimL>*Qvcb+l7$nLv-3y-bSp;VW*9wLyTSQZj{} zxW}{8ZQ@_M+0^8)HTUz=?j+-dFp{NwIE1&nkWDeuQ|a%ncqF7Krgrp8J~=b*N0 zk=w=t^@iqb>O~7i&-R1}SZOC#S6`&nwO>Sc=TxaK-v0nI?{r->{pyHeHhYGdRaHwReEhhhpaxHa zsB``;iCw0rzjdz+%?CFfDWK0qehzNh5cJ628X|1mlj7HRcztlRM=)2!KXFSHi^;(N zT8v0*Vv6k#K}>jo#>OmIxe*-%6IkDaZjDOjS`J4eQk$*CozLk}wm{_89}1>}#?R7V zX2S!?YlsK4`0EY7-?Y~I?c(;zSmn9G*E-b3=|0vlgMgq1i1S643M>-;a8t3Ts8m20^qgIX}w_He<@sa zoJW=di^#$D3I0WQQQ9lLLCD1BaM11D<$O@dLzp`EWcmLj^CAN>DTDoWh)~I`?!Rtm9Rj-fZs!%riFH_QZ>PLi8uV_=UOYorNy<*;yYUvS_3cZmyI)@DM;q z&%^8y=#`t`@jxcC6YOgHE4N1(6Lt?L!Nj;2g` zI7F~;hVhhzXiH|L3WzGmP!zdc`*M~{jR$MC=L}`jLv>$2 zt!k)k4eYwoTd{WoKN&nTJa?b=O7WR|v$uc1=Kf{phx1afaz#Cz`uB-noms=ogkP(v zmo0$A{fWhuZ|EsjY)6y;VRzqo9=e+=pJQyAdf$53sOaF(3Gsy{LWg}z=}a8HD~3B1 zn{+kNtAkPmQUe^omj;rj#U-(%kFWB1Rq+hQ`jy);Shz2`rnU#pbG8U&?HKj%&7(Wg zd_G_gxQx<8ZIt>6MOJdGwJ@O9?VC3!KDz#5Y4&ThU-O|->5UbEq~6dAsC}i>DF)v~ zwXCuM^`z@QA=*weRjZOxsbz4M+hjsLm-E`yG6r^8?6-sZr$8i8@mM%q9$F#evQhVK z1#`jF7(7}llof!vddkMDmr7ROMm@Vi?~%Q1K)fK(^InNHdkK~0s@D%H;-jZ$`1a6l z)AN7zJ}=g%Z#Z^_P{~U&Pi(Z)FH$sfDXAR7y~u zkp>SG(WzHog9BzLoiH1hR*P=P8B06l5L0iaw_^a4j2o1tTt0qqdq3-z+{Wp9w)msC zurY`WkpI`wbTYk(gI!|O^}NZ%LT6p^cme%UAk;#sH_we$EZk0n2E zjj`>-9w(lZNyKCG!+?(x+cys-qlVA$XM~1RtB{7_jhY`qSd_RMN;%u6E>p5^YLJhAa?%=%I7{@ptcZG^(nRE3K=Mhp4%(8ag)N=aZ@ zn|b`>9+CBBl+bhUW|T79?}4`dw4Q|%<`h=CXZk1}q|^vZWS*8A+DQ9PrzUf=>LOh( zpY>b;A0_nSk`z&Bh_aUST&aL4X4YOfjg8XBdjX{j1>n@?ZTNU^o`Ujt`yOmK3Wb?< zp8;i$m%^CARlfGtuCFik@S(gsri*)mQ z%(JvH2y>(qgPg>)F7Jn5X{jC-Ax0ufPg_mA<{A!grR}SG5?H&ElA5|qSy}{3o^LjH z7mKW=v$lp<5Sy`;q0M6(d?FVuKm1)`z+_&SPiD^o;#sgsr050bh&Wb|_Fv7N4ItTQ zaG2!gTHeG@&MkuS*OQmZRu-|*W;E-4*7Bf@qE2jx9Gpu0lq7-2Uej^bcJCx*tZ#BQ z^MJ<}etd&+MWl`JBUf|07o@)9eU;GXn zd7swX^uzgYoYfRqVJTcR>$IF%%XAfnLLG1Bhvg{2(%QjC-XuKG)zqmE1SMQA47DIk zvf{=YJRJ|Qes7K*gt6~I@<-C%(;$zgDoqy&JHx%D55D!99B`Dmo-LCo#hF>zZ1r^# ze1>t}D&d3T-3o_Shwx9@faL#k$C>9;f?^%`$L=dA>hsOYik(_XN}PV1q{M?FN-kGq zbK%$Kk-7DYFbsDSwR4UY9F1T9G;p7~NIl)^1(W6clXyI<&HTj4^G2v}DSXppBkK!` zYa<;i=s@I~CS*TF`zB0i`5M)Ck@TWx1E{d91hNgBT&(3UI3H;9Ft-~3L3+>PEf8Sw zxgkd|^!t#NhgA=u$fHWr`Fs2QOod^hGSaR^3UTcc&);4G?-a!pBf9HXxD()dY#6x< zTgE7^HaT;WVKWO<+!^n8CK%Zq-1;~*zdmQ@y0=p1ka;45Fg@u~F<5lSuihGP@F7^= zh<7M3rB;%JhTPUnGJzIvcByW-rRPZ{HdC;Z(aN?g>gs;pIY9a^8rx&N$R)*8%{r`ho^=!<~`tM~VJusBj{?>+Dt%hG#+Cr!>wS28l< z)yMZT`b^a!ORn!dpl#HFU2fz!pyeq>@0Dkd#i`@M_U3MU4jgXIxo>|(OJE|FzV&?u z80SO^>NaQEj;e?+6PCGg$RTwu834+O# zyb0n)g`ORDqg-DhH<3^chxz^2`K8~6gZB&c?yAlu6-_BL&3Z`mAV)>a-<#^SbC)Br zA}izq9G3#V;XR@vH-tpsFaVCCi@?QYhUWHiyX z8kvMrISr+Jg~$|}=cOTTcizZ;um?x{nwjK=y+(=f3a z*Z44BgST{MbC^I-RLuh{?Kr(+&Pw>lj=k#liw5s437X{{l}SmBtC@y}QN-AJ?1uwn zmpk!fp&>Yr9!l6)2h&_H_JG~cjE_devX#>yL2?DXpl~d#b`Sl?BBk(tXa*LdgCFtb z(RCR{Zi^fMtb_kK}_P(z9Rr;Q@+CT z6~wG(M}4BlgVwP$u!W9!E&&_I4zI!g`iuL9;YZ=lp#92|t)zmo1!<-@I<-7_ftl5a+%|}|Gri{kG zE(HV%#KoLszwWXM`_QT!bwj!u0K8?u>Kos;Oi%pi-RB~-Q6P+ZTMVNVNBgO4bV%7W zeSUbj5Pd2K z>`fRKhT9DKv5SA{w&7Gy)?CC8|Ilv20Q<7PDgcS-1j*|ax5dFUzTH}nM8?4$c*`Rq zMFvK`n{L~f-|zRg_K+Q?Dfs!teJH~UjS#4wd^6qqPCZW54_EdT3VO&~b*lIZRLL^; zHjyKUS+T&I1$1yQ9uFIkc+lQOnNBSc)`VT??$ktD3}i;suH$rJ>ml(WBkyBN!;mrj z2f>HVfCD@Ov(>uHZ*h8((dEv|oB8mm8?JKi)|+jcm-Jwsv?UKM#Iz7#y@-1lgIDy&@7ToS(peN>a{!eM%SFDEll>A9_7i;-iRdKMkOG^D^%85?g3xTR!;!7DK@=z zn|@#Xu|#Yjw*U8l5TjYjDQUP2#uNDk&*i z-o{W7JwtcdE`y@hl)L~6 z6M4~-|CG*7zeZ2L9(+KYkXB>dRq)D4`D67+Bb9$HC-)e_^zzuu1NU-V`T@bhPZICf zlY7A9MlNq18R!7)9BZHkX&;3(FA{1V_n%EF$lmn~I4;^*dc)-B20PqU_%4u}K_ki| z#Xz5d+RFo(Maefs4B6e8$paoC=Ox^SfMcEWAfF4Egrq+pOaP#zcD|disiKf%?bzHq zV8yghna5DPhrEp$_Gk3(YhP;AjAfjmrjU=kn%eSK%*#8TC*sRw=Df* zrT_z- z4n5m~(@L6RXj|~98iPpyI+?aS=uzVNx<*Gth4(p1{Pm2FQKBA2oLu`Ql;9S)JAD)b zsOq#CkESf^eaDbFt$%N3+e$YvSf=va$6cTYZsKk<@bF7v+nB`Of*j*KlYqYJ8 zl4;o`H3&tfsS5|9gF~4#d(v|~RDH|F(XXeJ%7a~c&?U4!$7tjc88gsVO%wA9gpJc) zzNn%3+{AGZNqBMtgg8F-X#65D_ZUj@D^{O=b)-HR0T*QnpqxO-_RJ~ z14(}zH5qjk*TgR`VcW(P@$dG@59FX06#CmCCo7oa08*s60@uX%4F+8NmQdx&pqRFV zzg`iOsnPZdgN48++$*U>S;sIX4)zRxqGIJA0I%d%g8_SRTNSQzMIg%Y%t!uM5sRNW2D`an^=pRhMn?7Oat0!dU5AsHO$HrP`f>xqS(@l)>TH{^lb)m6BzU4LmFR#xPHO`)ol z*BIQ}{PIx3X*6m7*HHaKz~-z%nqo;2VxWy0VxWFFiUt?!AQ(}cWkQPuAbDjjGU%LHw2%p9e=@ zN}J~MKI5fu{_Ua+0dlHe;`}_jE+__gqSw5(9JA9uu72dIKnSG7K+%8$GBvhvuK7P9 zJi?X9F6<8ME()I+8aVyVIJ3flk?V{wZ8Wp4uU@;B7x#YaXQ$QIaZNZ!xF|-p7&02~ zg512T@`|VN97l}{!3heT4LZs4T4fQ8{RqUBh?<-7@KAt5MJDqB@Dgl?|5&?G*U%c0 zEwyJkf-#N}l%-$mPBg)fgqa;Y) z>Ec=3sG|lGd|$thNv^soTsQAu)_=>%g#Tck3x_+M;B zeD+Ynn87X2W)ea&yPdNhy0iR4J$06!xvkYvaBB+%f4i^Oaw=Tnf*aRBsH+SohvHPZ zdws8!I^H;7|Mcj^L9Air*%dbZ8eS#F#ez!gmv-<3JLuo0D=Him>c}mA#>nxDF)pZ9tElBo)t%RCH)_jzFYlXjs>Y}Jho_;9iY6YY{25EfF z;23HJCt7-+fr)v)^tYLg;TNEj=6W{ysL531kq^cP&tIoXUon670cA(e}Hgk^$$Jia?HF3-p5NtNSB!;0cIClWOa-TPQL7Sx?Gi zhuKgCZPd}xP=(xAes7Z!`j!arxIlsI>*Q3RZ;h=SoS2g!>M*)oLI4v`*0qS-9C5jUV<~#t)jH#2y9~ z!Bx5Z(1ofxf3pQZ(_$t!w45gIf)l-bYel|E1eu%SfB0D`>^ z2bxHnkkZ2ADQ0;U+Wx=T&)yhlmNLA8hTug4HWPo~7e~7MsssaQINfFQTK+;bM?y)7 ztQtak-s*9Wh{!UifH@&dvlq~}V~q~;;lI3zOatVeIOd_jEM&t*DPK#33+L6p^B|_) zQ|uc;6|Jd988*W?Is>{F_2$_F5n1pb?=2J{7x&Bjd$&i$mb^7g(t-XhzWwrk8jJ}? z4MG=?4VRWR^~?Y|qv51^u2#yo(Xus3#Z1A#KW2w>yNqs8w=$jJe0p+4k`Q<$W0R00cs2Eo z3P6ndyYGC_`FnkWraq7wpz|2_E9JrY<)Z;kJVE@Uqj&QI6!`0it1>jVh~bQbfNQ19 zK+F1HXErEQ7etjVL@*b^1faV$+K4n2C=fV~3}I?EK1Fp$*C-|IK8`Oi72>}@Qz$zv z7mwb6<#$_Ux+xI~@be+&fG7rL#?+Y#yrJMn+-#txz<{R?GAV@`CVR5CbXp)95T#Wm zVH~V`fdxLE0o9x%C7yZX-&Ss&6T9|j*w}2X6_vht{n#M!xaFV zGmbjJJSZFWtQK!Q)mIcd`X0uGA-rB)~ZsTGM92(@S_w$C(1CDE9!3G@vDeS#asnwqd_cC6%ok zd%CrvY%Aq5j^9%LJtOfO#E;V49;V-0=dl>$2Gw>`Jr8*4yxgs<`0FhDQ{f zO888nMM){Ms-r9*=V?W#{BrW=N&s>r0oSiEx@jWBO978r!wN9LhixyNKe zvvq^sMOu$LWr*pqYR`fPB*5;e^ zKX7sC-`k$x!<+K+)hhw5w7RKCw9$~IkxjHJMaa_C@x%z}&w+BbbZnpq8Lwr3SHze* z>H)t|RUVvI_B$0)OSK%wIff98y-L~XLayY<-#IM2NM?}poqIrX@d|V2#FE~O|4Y$TUG@nO#SsTjGj9XausX3223`bAyT*@1-?wJDxB7mL-6WTx?}rzIW0 zCgM;^X3#+qFChS)8*o_ppHnOV1IRvAq39la))auL0NP$IK`+SfjZf(d1E682Z$~>8 z^K|NkAq&^T+WPlS@O;U4J)O*=lK<2s=6QCC?@mO^D46*k%^h5~(F}CF3dCDdkp#7Y zi#zzj1!`PrH_lkdikX85x2RnVSJufza7~VZdUd1wrQbDe@mMSES@WBRbP{ zi)weOKfBI%w2rjG1gMvuM=iw-7d1L8WfHSe|iDMKO)|XpC1W8(O z;nu~^g6@9U?g)m#B7X0%*i9%KSLrre_uoY@tFzoGoocnKL<^NXB!0as0# z8a+WUdL~5kDZeA=n8!?PzneRF{yUhnLztPH2=-?B2eIGeL4mO&M2)=kXCJXRcK0;u z6}XtV_H5;?eUEMLa%auM`f8%P%Tn5S-L>bso?Z-|#jVQaqk{o_B@q}BP0od z)G$$Hh?#bS^1Hlkbhnb1bLa~O5yj0l{&D`~Uih}GVhcKxK)Hd+!V21R{vM?qNuUX+ zj?Z3=6h<$2mJ1nl%`fPNb_y4!{R`+^vr^IR3)YCevS1xyzQ%L5#Hipcp#h{%^vdrM_z2GGX+@EbmQ z{j#K_H;0^g6ChOuOMvc*O2rtG1otHZF5AUe80ME9AY_wSJx0TyXS+0KHyOdDpzbK! z80l{mQY<}}ayR8jO%&P&4VV`^hOe2jzQnzC1mduQS(<`UZFJjFR6~a2$?x)rhB;ne1U)?tyPpZE3jAEqMxC# z+W0LYz%sTH0Zwk!j*epZwy*8m(sE^4(`V8!^h!H6lkZ>IT1bZX#)yR6)^$!7RyJF5 zwZi;<01EbKS_g7gU9l>N4Fn@XyI}q5{`K@fuzg1M&qu>p6MJFIG1;dC%toAsG}LuIOS;C;C5c+mpz{!vG|Nvn>BR0u`B!gv>oN&6 z%D&&tA*on1x4YF#xdu3uRI4f|9wpy}a@CI;hZ7+4A3zypmQIkkgZtz_1$*X^sdZ6A z&k4!NcsTs0>}=xZ5mdbvVjmnap=xAy$|9=XxYEsHOi2}6VA{O337)6W58FAIgxq2b zEbx3gtk(04H@Jt023wEq0MlvKopwDF0^~qn&Q`I?B>?U4GfLp=dN;+St%}s?gjN+j z>smIot2(sN{5qf(1}5mHx>+yQNaqeHWj4`BX9#63SYMVoFd0~n&DhUCJ|v#u{M;z> zLAgY$Q3U-e`%BNvTQBJyEub7-Z9@IvuffCs3_R@qTuxTV6E32|5|=#X-#NF8QEYx%ZcWMiKoe~% zwbT6aSQa1!47QHV!C)!{HrFwtEPwUyyrG{K-J=(Vxu`@y39?X22Uem3BO>c4IGd<= z)RSUV6lAu{_K)9AN}Z29+{6jTvCo88FGnsW{Wlq>#$5iXwO_L4k;0c!g{D`*_w(5S ziwkZbYKjAJgmJ)uS*O1A{|zU;%6N)~i>-jq7HH_O5*mc&+`Rw={EfxNpdB$;?9xl9 z#Rg~F(tpeKn9EaJSug?(v)t;p1oGa_3;L-xg~LK~CUN-crTh{p`Y+A0Y$EBH0(eON zofia#7hCnvs?g|l@R(l1q6bDW%rIAv#?RngrWRVM!v6p7NNcMq+W*Q0{BnH(p%3n% zU(0B3S)DepfOh8Il}7!`SYHgCkG?|BPauAtKaL#)0^-|H&mWI(S$ACk3nw&7L;hMA z?py0}-6(w&uEnbB_uaqkDoLxLR)6y=yc*10|KMNdqY{{_0@Jh=t|-VJEp`LVDD0pM zkWEd4Zat(I%m9s*?X`$D(GwijIF9De-QHwq*_ z>i%ER)AksFy^>$hh9IrIc&?M_4Q%NYBJlUh$H10%71}A&w{-4Dza(#Eb}Fg0#{sZM z4PSBCpX*G;Ne&6j3oBXGlvPh7rH_BBOI?$Gn{Lq4d5Gx9u`MV0>q3rbV%f`OstZLs z4GO4{fP4&omTrMP^y6UFBCS_{jm$sAxGTt)w4U!Y>pgzvVrdASF5`<3SA&dX45s|G z?j45vSR_rRe`I}uKEq*Bf#3DeR=J?A0oVWg{RM+e3lWiJM260Js*e_&)dSAIHA!n)4e2M`dKF0JT5izMh{K6_AX1m#X<0#^Moja}wo}JPfwI*5ZtOnc!mGytP=@pXdl5f2~^$G@ErEsN;^T96VdSmEc>4$p!JJn zlC%$*xe)@!j|{>^lhA7)XTU%3{XIqggK43>&Kjd!ZW}Y{LB_>cugdM3VlY5e9L!~p zd;BkwzTnicaTUG^EG>=sW!5ey(8$8wr5$u3@xETnhBys5qP4SP^oJF+Z_x%ragXAF zS;6Yh|KHlm&zhH)hb1%!Yc>DeJo#$Jstw&cB!uta!giPwS1LctmZi4*k|)fRr- zJr8n$%gZBInr(KS6dV_|Bv1$GgpU}d2WxgPya#UVk&o<{dYNf)QBrEg&-B<=8@qu` zy_h##*@!@3Z0Fpet@mkPLcvdWap&h-4mjM8lYH~P=kQwpy(Xu;U+E?=A|mI470Vn^ z@uStv3;!HnG@&Fafki!(?Nhp2q`P|G!aXHP*LS8NrQN26&5x8UEG16NzjtEN9WjYY zcVJX3myYyb-m`R1iP!R-eh9@KjzIr4?o*d|W50OjQcGQf1J=O(g8pWk*Oko!ZpeQA zt|%AbjD!ZD$%}ryOIY#$apANVTMlU43za-ouXOI$tz5mQOYd5p`ih9e21llKUEDnX zHk3yuTxnP!d53Rh`=UKd?lFlW9nO;{R1g-U6BG1w%Yh?1OwOI1d$k5Q?fdLzp&B9z zw&*gZA9a0m_sqL32TVQ&G?m=}&O4O7(YlUw8jc|cFfhN#E5F%sV2`;($2W@?5^u|^ zw4NS)`E7#*!nYpNfF79ifLms+n1s$>rE@9eKAUa=&-nP1W`62N2 zF{5E8!1L68jtYM`aQ%GDOP1G&>atM;6pIznsS5RBQW(S@f7!W^{_ Alert { @@ -106,7 +109,11 @@ struct ChatListView_Previews: PreviewProvider { ) ] - return ChatListView(user: User.sampleData) - .environmentObject(chatModel) + 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 index 012f57791f..741b55e9a2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -72,7 +72,7 @@ struct ChatPreviewView: View { struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { - Group{ + Group { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [] diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index ba495dfd21..0064ac9292 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -21,7 +21,7 @@ struct NewChatButton: View { var body: some View { Button { showAddChat = true } label: { - Image(systemName: "square.and.pencil") + Image(systemName: "person.crop.circle.badge.plus") } .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) { Button("Add contact") { addContactAction() } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 2410ff37ba..74015c2def 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -11,7 +11,8 @@ 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 @@ -22,29 +23,45 @@ struct TerminalView: View { ScrollView { Text(item.details) .textSelection(.enabled) + .padding() } } label: { - Text(item.label) - .frame(width: 360, height: 30, alignment: .leading) + 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) + SendMessageView( + sendMessage: sendMessage, + inProgress: inProgress, + keyboardVisible: $keyboardVisible + ) } } .navigationViewStyle(.stack) .navigationTitle("Chat console") } - func scrollToBottom(_ proxy: ScrollViewProxy) { + func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { if let id = chatModel.terminalItems.last?.id { - withAnimation { + withAnimation(animation) { proxy.scrollTo(id, anchor: .bottom) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift index f735214086..7bfc2eec49 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift @@ -17,7 +17,7 @@ struct SettingsButton: View { Image(systemName: "gearshape") } .sheet(isPresented: $showSettings, content: { - SettingsView() + SettingsView(showSettings: $showSettings) .onAppear { do { chatModel.userAddress = try apiGetUserAddress() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 9d901956e3..35d969e1f0 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -8,8 +8,11 @@ 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! @@ -44,11 +47,59 @@ struct SettingsView: View { } } + 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: { - Text("Chat console") + 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)") } } @@ -65,7 +116,9 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.currentUser = User.sampleData - return SettingsView() + @State var showSettings = false + + return SettingsView(showSettings: $showSettings) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift index 0daf7c44db..02ab3d1653 100644 --- a/apps/ios/Shared/Views/WelcomeView.swift +++ b/apps/ios/Shared/Views/WelcomeView.swift @@ -13,30 +13,44 @@ struct WelcomeView: View { @State var fullName: String = "" var body: some View { - VStack(alignment: .leading) { - Text("Create profile") - .font(.largeTitle) - .padding(.bottom) - Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.") - .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)") + 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)") + } } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 84ada554d9..752c52f814 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -21,17 +21,19 @@ 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 */; }; - 5C35CF6F27B031FB00FB6C6D /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6A27B031FB00FB6C6D /* libgmpxx.a */; }; - 5C35CF7027B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6B27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a */; }; - 5C35CF7127B031FB00FB6C6D /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6C27B031FB00FB6C6D /* libgmp.a */; }; - 5C35CF7227B031FB00FB6C6D /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6D27B031FB00FB6C6D /* libffi.a */; }; - 5C35CF7327B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */; }; 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 */; }; @@ -115,15 +117,16 @@ 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 = ""; }; - 5C35CF6A27B031FB00FB6C6D /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C35CF6B27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a"; sourceTree = ""; }; - 5C35CF6C27B031FB00FB6C6D /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C35CF6D27B031FB00FB6C6D /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a"; 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 = ""; }; @@ -166,14 +169,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C35CF7027B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a in Frameworks */, - 5C35CF6F27B031FB00FB6C6D /* libgmpxx.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, + 5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C35CF7327B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a in Frameworks */, - 5C35CF7227B031FB00FB6C6D /* libffi.a in Frameworks */, - 5C35CF7127B031FB00FB6C6D /* libgmp.a 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; }; @@ -233,11 +236,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C35CF6D27B031FB00FB6C6D /* libffi.a */, - 5C35CF6C27B031FB00FB6C6D /* libgmp.a */, - 5C35CF6A27B031FB00FB6C6D /* libgmpxx.a */, - 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */, - 5C35CF6B27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ.a */, + 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 = ""; @@ -365,6 +368,7 @@ isa = PBXGroup; children = ( 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, + 5C5346A727B59A6A004DF848 /* ChatHelp.swift */, 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, @@ -553,6 +557,7 @@ 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 */, @@ -592,6 +597,7 @@ 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 */, @@ -775,7 +781,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -792,10 +798,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; + LIBRARY_SEARCH_PATHS = ""; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 0.2.2; + MARKETING_VERSION = 0.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -815,7 +821,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -832,10 +838,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libraries"; + LIBRARY_SEARCH_PATHS = ""; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 0.2.2; + MARKETING_VERSION = 0.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From 92409820fb67e7f3ba81f629e418393256adfa55 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 11 Feb 2022 12:03:34 +0400 Subject: [PATCH 81/82] enable async commands (#290) * enable async * fix async command error response Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 20 ++++++++++---------- src/Simplex/Chat/View.hs | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bb700c3425..ff7932c709 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -384,17 +384,17 @@ processChatCommand = \case 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 . CRChatCmdError)) - -- pure $ CRCmdAccepted corrId - -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse - procCmd = id + 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 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4b95f0757c..ffbf8f1ce2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -122,8 +122,8 @@ responseToView cmd testView = \case where r = (plain cmd :) -- this function should be `r` for "synchronous", `id` for "asynchronous" command responses - -- r' = id - r' = r + -- r' = r + r' = id testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where From 0ea870501462b8f425f3f5edc2bff3e96f0b0c65 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Fri, 11 Feb 2022 12:05:22 +0400 Subject: [PATCH 82/82] 1.1.1