diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml new file mode 100644 index 0000000000..6682641419 --- /dev/null +++ b/.github/actions/prepare-build/action.yml @@ -0,0 +1,52 @@ +name: "Prebuilt steps for build" +description: "Reusable steps for multiple jobs" +inputs: + java_ver: + required: true + description: "Java version to install" + ghc_ver: + required: true + description: "GHC version to install" + github_ref: + required: true + description: "Git reference" + os: + required: true + description: "Target OS" + cache_path: + required: false + default: "~/.cabal/store" + description: "Cache path" + cabal_ver: + required: false + default: 3.10.1.0 + description: "GHC version to install" +runs: + using: "composite" + steps: + - name: Skip unreliable ghc 8.10.7 build on stable branch + shell: bash + if: inputs.ghc_ver == '8.10.7' && inputs.github_ref == 'refs/heads/stable' + run: exit 0 + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ inputs.ghc_ver }} + cabal-version: ${{ inputs.cabal_ver }} + + - name: Setup Java + if: startsWith(inputs.github_ref, 'refs/tags/v') + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: ${{ inputs.java_ver }} + cache: 'gradle' + + - name: Restore cached build + uses: actions/cache@v4 + with: + path: | + ${{ inputs.cache_path }} + dist-newstyle + key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml new file mode 100644 index 0000000000..e44e6ef0f2 --- /dev/null +++ b/.github/actions/prepare-release/action.yml @@ -0,0 +1,39 @@ +name: "Upload binary and update hash" +description: "Reusable steps for multiple jobs" +inputs: + bin_path: + required: true + description: "Path to binary to upload" + bin_name: + required: true + description: "Name of uploaded binary" + bin_hash: + required: true + description: "Message with SHA to include in release" + github_ref: + required: true + description: "Github reference" + github_token: + required: true + description: "Github token" +runs: + using: "composite" + steps: + - name: Linux upload AppImage to release + if: startsWith(inputs.github_ref, 'refs/tags/v') + uses: simplex-chat/upload-release-action@v2 + with: + repo_token: ${{ inputs.github_token }} + file: ${{ inputs.bin_path }} + asset_name: ${{ inputs.bin_name }} + tag: ${{ inputs.github_ref }} + + - name: Linux update AppImage hash + if: startsWith(inputs.github_ref, 'refs/tags/v') + uses: simplex-chat/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + with: + append_body: true + body: | + ${{ inputs.bin_hash }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2338258d82..de0b976bcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,17 +22,58 @@ on: - "README.md" - "PRIVACY.md" +# This workflow uses custom actions (prepare-build and prepare-release) defined in: +# +# .github/actions/ +# ├── prepare-build +# │ └── action.yml +# └── prepare-release +# └── action.yml + +# Important! +# Do not use always(), it makes build unskippable. +# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753 + jobs: - prepare-release: - if: startsWith(github.ref, 'refs/tags/v') + +# ============================= +# Global variables +# ============================= + +# That is the only and less hacky way to setup global variables +# to use in strategy matrix (env:/YAML anchors doesn't work). +# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789 +# https://github.com/actions/runner/issues/1182 +# https://stackoverflow.com/a/77549656 + + variables: + runs-on: ubuntu-latest + outputs: + GHC_VER: 9.6.3 + JAVA_VER: 17 + steps: + - name: Dummy job when we have just simple variables + if: false + run: echo + +# ============================= +# Create release +# ============================= + +# Create release, but only if it's triggered by tag push. +# On pull requests/commits push, this job will always complete. + + maybe-release: runs-on: ubuntu-latest steps: - name: Clone project + if: startsWith(github.ref, 'refs/tags/v') uses: actions/checkout@v3 - name: Build changelog id: build_changelog - uses: mikepenz/release-changelog-builder-action@v4 + if: startsWith(github.ref, 'refs/tags/v') + uses: simplex-chat/release-changelog-builder-action@v4 with: configuration: .github/changelog_conf.json failOnError: true @@ -42,7 +83,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release - uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/v') + uses: simplex-chat/action-gh-release@v1 with: body: ${{ steps.build_changelog.outputs.changelog }} prerelease: true @@ -52,183 +94,259 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - build: - name: build-${{ matrix.os }}-${{ matrix.ghc }} - if: always() - needs: prepare-release - runs-on: ${{ matrix.os }} +# ========================= +# Linux Build +# ========================= + + build-linux: + name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + runs-on: ubuntu-${{ matrix.os }} strategy: fail-fast: false matrix: include: - - os: ubuntu-20.04 + - os: 20.04 ghc: "8.10.7" - cache_path: ~/.cabal/store - - os: ubuntu-20.04 - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-ubuntu-20_04-x86-64 + - os: 20.04 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-ubuntu-20_04-x86-64 desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb - - os: ubuntu-22.04 - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-ubuntu-22_04-x86-64 + - os: 22.04 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-ubuntu-22_04-x86-64 desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb - - os: macos-latest - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-macos-aarch64 - desktop_asset_name: simplex-desktop-macos-aarch64.dmg - - os: macos-13 - ghc: "9.6.3" - cache_path: ~/.cabal/store - asset_name: simplex-chat-macos-x86-64 - desktop_asset_name: simplex-desktop-macos-x86_64.dmg - - os: windows-latest - ghc: "9.6.3" - cache_path: C:/cabal - asset_name: simplex-chat-windows-x86-64 - desktop_asset_name: simplex-desktop-windows-x86_64.msi - steps: - - name: Skip unreliable ghc 8.10.7 build on stable branch - if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable' - run: exit 0 - - - name: Configure pagefile (Windows) - if: matrix.os == 'windows-latest' - uses: al-cheb/configure-pagefile-action@v1.3 - with: - minimum-size: 16GB - maximum-size: 16GB - disk-root: "C:" - - - name: Clone project + - name: Checkout Code uses: actions/checkout@v3 - - name: Setup Haskell - uses: haskell-actions/setup@v2 - with: - ghc-version: ${{ matrix.ghc }} - cabal-version: "3.10.1.0" + # Otherwise we run out of disk space with Docker build + - name: Free disk space + shell: bash + run: ./scripts/ci/linux_util_free_space.sh - name: Restore cached build - id: restore_cache - uses: actions/cache/restore@v3 + uses: actions/cache@v4 with: path: | - ${{ matrix.cache_path }} + ~/.cabal/store dist-newstyle - key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} + key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - # / Unix + - name: Set up Docker Buildx + uses: simplex-chat/docker-setup-buildx-action@v3 - - name: Unix prepare cabal.project.local for Mac - if: matrix.os == 'macos-latest' + - name: Build and cache Docker image + uses: simplex-chat/docker-build-push-action@v6 + with: + context: . + load: true + file: Dockerfile.build + tags: build/${{ matrix.os }}:latest + build-args: | + TAG=${{ matrix.os }} + GHC=${{ matrix.ghc }} + + # Docker needs these flags for AppImage build: + # --device /dev/fuse + # --cap-add SYS_ADMIN + # --security-opt apparmor:unconfined + - name: Start container shell: bash run: | - echo "ignore-project: False" >> cabal.project.local - echo "package simplexmq" >> cabal.project.local - echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local - echo "" >> cabal.project.local - echo "package direct-sqlcipher" >> cabal.project.local - echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local - echo " flags: +openssl" >> cabal.project.local + docker run -t -d \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + --security-opt apparmor:unconfined \ + --name builder \ + -v ~/.cabal:/root/.cabal \ + -v /home/runner/work/_temp:/home/runner/work/_temp \ + -v ${{ github.workspace }}:/project \ + build/${{ matrix.os }}:latest - - name: Unix prepare cabal.project.local for Mac - if: matrix.os == 'macos-13' - shell: bash - run: | - echo "ignore-project: False" >> cabal.project.local - echo "package simplexmq" >> cabal.project.local - echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local - echo "" >> cabal.project.local - echo "package direct-sqlcipher" >> cabal.project.local - echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local - echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local - echo " flags: +openssl" >> cabal.project.local - - - name: Install AppImage dependencies - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - run: sudo apt install -y desktop-file-utils - - - name: Install openssl for Mac - if: matrix.os == 'macos-latest' || matrix.os == 'macos-13' - run: brew install openssl@3.0 - - - name: Unix prepare cabal.project.local for Ubuntu - if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04' + - name: Prepare cabal.project.local shell: bash run: | echo "ignore-project: False" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local - - name: Unix build CLI - id: unix_cli_build - if: matrix.os != 'windows-latest' + # chmod/git commands are used to workaround permission issues when cache is restored + - name: Build CLI + shell: docker exec -t builder sh -eu {0} + run: | + chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*' + cabal clean + cabal update + cabal build -j --enable-tests + mkdir -p /out + for i in simplex-chat simplex-chat-test; do + bin=$(find /project/dist-newstyle -name "$i" -type f -executable) + chmod +x "$bin" + mv "$bin" /out/ + done + strip /out/simplex-chat + + - name: Copy tests from container shell: bash run: | - cabal build --enable-tests - path=$(cabal list-bin simplex-chat) - echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + docker cp builder:/out/simplex-chat-test . - - name: Unix upload CLI binary to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.unix_cli_build.outputs.bin_path }} - asset_name: ${{ matrix.asset_name }} - tag: ${{ github.ref }} - - - name: Unix update CLI binary hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.unix_cli_build.outputs.bin_hash }} - - - name: Setup Java - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name - uses: actions/setup-java@v3 - with: - distribution: 'corretto' - java-version: '17' - cache: 'gradle' - - - name: Linux build desktop - id: linux_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') + - name: Copy CLI from container and prepare it + id: linux_cli_prepare + if: startsWith(github.ref, 'refs/tags/v') && matrix.cli_asset_name shell: bash + run: | + docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }} + path="${{ github.workspace }}/${{ matrix.cli_asset_name }}" + echo "bin_path=$path" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') && matrix.cli_asset_name + uses: ./.github/actions/prepare-release + with: + bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }} + bin_name: ${{ matrix.cli_asset_name }} + bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Desktop + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + shell: docker exec -t builder sh -eu {0} run: | scripts/desktop/build-lib-linux.sh cd apps/multiplatform ./gradlew packageDeb - path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb) + + - name: Prepare Desktop + id: linux_desktop_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + shell: bash + run: | + path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb ) echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Linux make AppImage - id: linux_appimage_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - shell: bash + - name: Upload Desktop + uses: ./.github/actions/prepare-release + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name + with: + bin_path: ${{ steps.linux_desktop_build.outputs.package_path }} + bin_name: ${{ matrix.desktop_asset_name }} + bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build AppImage + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + shell: docker exec -t builder sh -eu {0} run: | scripts/desktop/make-appimage-linux.sh - path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage) + + - name: Prepare AppImage + id: linux_appimage_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + shell: bash + run: | + path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage) echo "appimage_path=$path" >> $GITHUB_OUTPUT echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Mac build desktop + - name: Upload AppImage + if: startsWith(github.ref, 'refs/tags/v') && matrix.desktop_asset_name && matrix.os == '20.04' + uses: ./.github/actions/prepare-release + with: + bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }} + bin_name: "simplex-desktop-x86_64.AppImage" + bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Fix permissions for cache + shell: bash + run: | + sudo chmod -R 777 dist-newstyle ~/.cabal + sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + + - name: Run tests + shell: bash + run: | + ./simplex-chat-test + +# ========================= +# MacOS Build +# ========================= + + build-macos: + name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-macos-aarch64 + desktop_asset_name: simplex-desktop-macos-aarch64.dmg + openssl_dir: "/opt/homebrew/opt" + - os: macos-13 + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-macos-x86-64 + desktop_asset_name: simplex-desktop-macos-x86_64.dmg + openssl_dir: "/usr/local/opt" + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Prepare build + uses: ./.github/actions/prepare-build + with: + java_ver: ${{ needs.variables.outputs.JAVA_VER }} + ghc_ver: ${{ matrix.ghc }} + os: ${{ matrix.os }} + github_ref: ${{ github.ref }} + + - name: Install OpenSSL + run: brew install openssl@3.0 + + - name: Prepare cabal.project.local + shell: bash + run: | + echo "ignore-project: False" >> cabal.project.local + echo "package simplexmq" >> cabal.project.local + echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local + echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local + echo "" >> cabal.project.local + echo "package direct-sqlcipher" >> cabal.project.local + echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local + echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local + echo " flags: +openssl" >> cabal.project.local + + - name: Build CLI + id: mac_cli_build + shell: bash + run: | + cabal build -j --enable-tests + path=$(cabal list-bin simplex-chat) + echo "bin_path=$path" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release + with: + bin_path: ${{ steps.mac_cli_build.outputs.bin_path }} + bin_name: ${{ matrix.cli_asset_name }} + bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Desktop id: mac_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') + if: startsWith(github.ref, 'refs/tags/v') shell: bash env: APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} @@ -240,86 +358,59 @@ jobs: echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Linux upload desktop package to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') - uses: svenstaro/upload-release-action@v2 + - name: Upload Desktop + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.linux_desktop_build.outputs.package_path }} - asset_name: ${{ matrix.desktop_asset_name }} - tag: ${{ github.ref }} + bin_path: ${{ steps.mac_desktop_build.outputs.package_path }} + bin_name: ${{ matrix.desktop_asset_name }} + bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Linux update desktop package hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.linux_desktop_build.outputs.package_hash }} - - - name: Linux upload AppImage to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.linux_appimage_build.outputs.appimage_path }} - asset_name: simplex-desktop-x86_64.AppImage - tag: ${{ github.ref }} - - - name: Linux update AppImage hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.linux_appimage_build.outputs.appimage_hash }} - - - name: Mac upload desktop package to release - if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.mac_desktop_build.outputs.package_path }} - asset_name: ${{ matrix.desktop_asset_name }} - tag: ${{ github.ref }} - - - name: Mac update desktop package hash - if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.mac_desktop_build.outputs.package_hash }} - - - name: Cache unix build - uses: actions/cache/save@v3 - if: matrix.os != 'windows-latest' - with: - path: | - ${{ matrix.cache_path }} - dist-newstyle - key: ${{ steps.restore_cache.outputs.cache-primary-key }} - - - name: Unix test - if: matrix.os != 'windows-latest' + - name: Run tests timeout-minutes: 40 shell: bash run: cabal test --test-show-details=direct - # Unix / +# ========================= +# Windows Build +# ========================= - # / Windows - # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + build-windows: + name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + ghc: ${{ needs.variables.outputs.GHC_VER }} + cli_asset_name: simplex-chat-windows-x86-64 + desktop_asset_name: simplex-desktop-windows-x86_64.msi + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Prepare build + uses: ./.github/actions/prepare-build + with: + java_ver: ${{ needs.variables.outputs.JAVA_VER }} + ghc_ver: ${{ matrix.ghc }} + os: ${{ matrix.os }} + cache_path: "C:/cabal" + github_ref: ${{ github.ref }} + + - name: Configure pagefile (Windows) + uses: simplex-chat/configure-pagefile-action@v1.4 + with: + minimum-size: 16GB + maximum-size: 16GB + disk-root: "C:" + - name: 'Setup MSYS2' - if: matrix.os == 'windows-latest' - uses: msys2/setup-msys2@v2 + uses: simplex-chat/setup-msys2@v2 with: msystem: ucrt64 update: true @@ -331,10 +422,9 @@ jobs: toolchain:p cmake:p - - - name: Windows build - id: windows_build - if: matrix.os == 'windows-latest' + # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing + - name: Build CLI + id: windows_cli_build shell: msys2 {0} run: | export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) @@ -349,70 +439,42 @@ jobs: rm -rf dist-newstyle/src/direct-sq* sed -i "s/, unix /--, unix /" simplex-chat.cabal - cabal build --enable-tests + cabal build -j --enable-tests rm -rf dist-newstyle/src/direct-sq* path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-512\(${{ matrix.cli_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Windows upload CLI binary to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: svenstaro/upload-release-action@v2 + - name: Upload CLI + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.windows_build.outputs.bin_path }} - asset_name: ${{ matrix.asset_name }} - tag: ${{ github.ref }} + bin_path: ${{ steps.windows_cli_build.outputs.bin_path }} + bin_name: ${{ matrix.cli_asset_name }} + bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Windows update CLI binary hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.windows_build.outputs.bin_hash }} - - - name: Windows build desktop + - name: Build Desktop id: windows_desktop_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + if: startsWith(github.ref, 'refs/tags/v') shell: msys2 {0} run: | export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) scripts/desktop/build-lib-windows.sh cd apps/multiplatform ./gradlew packageMsi + rm -rf dist-newstyle/src/direct-sq* path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') echo "package_path=$path" >> $GITHUB_OUTPUT echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - - name: Windows upload desktop package to release - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: svenstaro/upload-release-action@v2 + - name: Upload Desktop + if: startsWith(github.ref, 'refs/tags/v') + uses: ./.github/actions/prepare-release with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.windows_desktop_build.outputs.package_path }} - asset_name: ${{ matrix.desktop_asset_name }} - tag: ${{ github.ref }} - - - name: Windows update desktop package hash - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - append_body: true - body: | - ${{ steps.windows_desktop_build.outputs.package_hash }} - - - name: Cache windows build - uses: actions/cache/save@v3 - if: matrix.os == 'windows-latest' - with: - path: | - ${{ matrix.cache_path }} - dist-newstyle - key: ${{ steps.restore_cache.outputs.cache-primary-key }} - - # Windows / + bin_path: ${{ steps.windows_desktop_build.outputs.package_path }} + bin_name: ${{ matrix.desktop_asset_name }} + bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml new file mode 100644 index 0000000000..7de44addc7 --- /dev/null +++ b/.github/workflows/reproduce-schedule.yml @@ -0,0 +1,45 @@ +name: Reproduce latest release + +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # every day at 02:00 night + +jobs: + reproduce: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Get latest release + shell: bash + run: | + curl --proto '=https' \ + --tlsv1.2 \ + -sSf -L \ + 'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \ + 2>/dev/null | \ + grep -i "tag_name" | \ + awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV + + - name: Execute reproduce script + run: | + ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG" + + - name: Check if build has been reproduced + env: + url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }} + user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }} + pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }} + run: | + if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then + exit 0 + else + curl --proto '=https' --tlsv1.2 -sSf \ + -u "${user}:${pass}" \ + -H 'Content-Type: application/json' \ + -d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \ + "$url" + exit 1 + fi diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 6839d48aeb..5fbe8293bc 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -33,7 +33,7 @@ jobs: ./website/web.sh - name: Deploy - uses: peaceiris/actions-gh-pages@v3 + uses: simplex-chat/actions-gh-pages@v3 with: publish_dir: ./website/_site github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000000..76bb1127f2 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,92 @@ +# syntax=docker/dockerfile:1.7.0-labs +ARG TAG=24.04 +FROM ubuntu:${TAG} AS build + +### Build stage + +ARG GHC=9.6.3 +ARG CABAL=3.10.1.0 +ARG JAVA=17 + +ENV TZ=Etc/UTC \ + DEBIAN_FRONTEND=noninteractive + +# Install curl, git and and simplex-chat dependencies +RUN apt-get update && \ + apt-get install -y curl \ + libpq-dev \ + git \ + sqlite3 \ + libsqlite3-dev \ + build-essential \ + libgmp3-dev \ + zlib1g-dev \ + llvm \ + cmake \ + llvm-dev \ + libnuma-dev \ + libssl-dev \ + desktop-file-utils \ + patchelf \ + ca-certificates \ + zip \ + wget \ + fuse3 \ + file \ + appstream \ + gpg \ + unzip &&\ + ln -s /bin/fusermount /bin/fusermount3 || : + +# Install Java Coretto +# Required, because official Java in Ubuntu +# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final +# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin +# to fix this :( +RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\ + echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\ + apt update &&\ + apt install -y java-${JAVA}-amazon-corretto-jdk + +# Specify bootstrap Haskell versions +ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} +ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} + +# Do not install Stack +ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true +ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true + +# Install ghcup +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh + +# Adjust PATH +ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" + +# Set both as default +RUN ghcup set ghc "${GHC}" && \ + ghcup set cabal "${CABAL}" + +#===================== +# Install Android SDK +#===================== +ARG SDK_VERSION=13114758 + +ENV SDK_VERSION=$SDK_VERSION \ + ANDROID_HOME=/root + +RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \ + unzip tools.zip && rm tools.zip && \ + mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \ + ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest" + +ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin" + +# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded +RUN mkdir -p ~/.android ~/.gradle && \ + touch ~/.android/repositories.cfg && \ + echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\ + yes | sdkmanager --licenses >/dev/null + +ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools + +WORKDIR /project diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 305ad0a601..e8b494724a 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -443,12 +443,12 @@ struct ContentView: View { } func connectViaUrl() { - dismissAllSheets() { - let m = ChatModel.shared - if let url = m.appOpenUrl { - m.appOpenUrl = nil + let m = ChatModel.shared + if let url = m.appOpenUrl { + m.appOpenUrl = nil + dismissAllSheets() { var path = url.path - if (path == "/contact" || path == "/invitation") { + if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { path.removeFirst() let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 495209499c..2de818abb2 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -839,13 +839,14 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo return nil } -func apiAddContact(incognito: Bool) async -> ((String, PendingContactConnection)?, Alert?) { +func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactConnection)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiAddContact: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false) - if case let .invitation(_, connReqInvitation, connection) = r { return ((connReqInvitation, connection), nil) } + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let r = await chatSendCmd(.apiAddContact(userId: userId, short: short, incognito: incognito), bgTask: false) + if case let .invitation(_, connLinkInv, connection) = r { return ((connLinkInv, connection), nil) } let alert = connectionErrorAlert(r) return (nil, alert) } @@ -856,23 +857,26 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P throw r } -func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? { +func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection { let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection} throw r } -func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { - let userId = try currentUserId("apiConnectPlan") - let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) - if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } - logger.error("apiConnectPlan error: \(responseError(r))") - throw r +func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiConnectPlan: no current user") + return (nil, nil) + } + let r = await chatSendCmd(.apiConnectPlan(userId: userId, connLink: connLink)) + if case let .connectionPlan(_, connLink, connPlan) = r { return ((connLink, connPlan), nil) } + let alert = apiConnectResponseAlert(r) ?? connectionErrorAlert(r) + return (nil, alert) } -func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? { - let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq) +func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqType, PendingContactConnection)? { + let (r, alert) = await apiConnect_(incognito: incognito, connLink: connLink) if let alert = alert { AlertManager.shared.showAlert(alert) return nil @@ -881,12 +885,12 @@ func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, Pending } } -func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { +func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnect: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) + let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink)) let m = ChatModel.shared switch r { case let .sentConfirmation(_, connection): @@ -899,20 +903,31 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi } let alert = contactAlreadyExistsAlert(contact) return (nil, alert) + default: () + } + let alert = apiConnectResponseAlert(r) ?? connectionErrorAlert(r) + return (nil, alert) +} + +private func apiConnectResponseAlert(_ r: ChatResponse) -> Alert? { + switch r { case .chatCmdError(_, .error(.invalidConnReq)): - let alert = mkAlert( + mkAlert( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) - return (nil, alert) + case .chatCmdError(_, .error(.unsupportedConnReq)): + mkAlert( + title: "Unsupported connection link", + message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." + ) case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): - let alert = mkAlert( + mkAlert( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) - return (nil, alert) case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))): - let alert = Alert( + Alert( title: Text("Connection blocked"), message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), primaryButton: .default(Text("Ok")), @@ -922,25 +937,22 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi } } ) - return (nil, alert) case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): - let alert = mkAlert( + mkAlert( title: "Undelivered messages", message: "The connection reached the limit of undelivered messages, your contact may be offline." ) - return (nil, alert) case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): if internalErr == "SEUniqueID" { - let alert = mkAlert( + mkAlert( title: "Already connected?", message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))." ) - return (nil, alert) + } else { + nil } - default: () + default: nil } - let alert = connectionErrorAlert(r) - return (nil, alert) } func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { @@ -1130,10 +1142,10 @@ func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bo } -func apiCreateUserAddress() async throws -> String { +func apiCreateUserAddress(short: Bool) async throws -> CreatedConnLink { let userId = try currentUserId("apiCreateUserAddress") - let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) - if case let .userContactLinkCreated(_, connReq) = r { return connReq } + let r = await chatSendCmd(.apiCreateMyAddress(userId: userId, short: short)) + if case let .userContactLinkCreated(_, connLink) = r { return connLink } throw r } @@ -1642,15 +1654,16 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r } -func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { - let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) - if case let .groupLinkCreated(_, _, connReq, memberRole) = r { return (connReq, memberRole) } +func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short)) + if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) } throw r } -func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { +func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) - if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) } + if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } throw r } @@ -1660,11 +1673,11 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { throw r } -func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? { +func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, _, connReq, memberRole): - return (connReq, memberRole) + case let .groupLink(_, _, connLink, memberRole): + return (connLink, memberRole) case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 10120db185..f8d69c5fc8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -19,6 +19,7 @@ struct SimpleXApp: App { @Environment(\.scenePhase) var scenePhase @State private var enteredBackgroundAuthenticated: TimeInterval? = nil + @State private var appOpenUrlLater: URL? init() { DispatchQueue.global(qos: .background).sync { @@ -42,7 +43,11 @@ struct SimpleXApp: App { .environmentObject(AppTheme.shared) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") - chatModel.appOpenUrl = url + if AppChatState.shared.value == .active { + chatModel.appOpenUrl = url + } else { + appOpenUrlLater = url + } } .onAppear() { // Present screen for continue migration if it wasn't finished yet @@ -93,7 +98,16 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } + if let url = appOpenUrlLater { + await MainActor.run { + appOpenUrlLater = nil + chatModel.appOpenUrl = url + } + } } + } else if let url = appOpenUrlLater { + appOpenUrlLater = nil + chatModel.appOpenUrl = url } } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 8fe4260a1e..8194c8fe6f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import SimpleXChat +@preconcurrency import SimpleXChat func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View { HStack { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 693efcfbb5..eae28b76be 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -45,7 +45,7 @@ struct ChatView: View { @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @State private var showGroupLinkSheet: Bool = false - @State private var groupLink: String? + @State private var groupLink: CreatedConnLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var forwardedChatItems: [ChatItem] = [] @State private var selectedChatItems: Set? = nil diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 56d994b397..9fa07bc391 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -21,7 +21,7 @@ struct GroupChatInfoView: View { @State var localAlias: String @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil - @State private var groupLink: String? + @State private var groupLink: CreatedConnLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkNavLinkActive: Bool = false @State private var addMembersNavLinkActive: Bool = false diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 39288e2d52..a11c073a42 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -10,12 +10,14 @@ import SwiftUI import SimpleXChat struct GroupLinkView: View { + @EnvironmentObject var theme: AppTheme var groupId: Int64 - @Binding var groupLink: String? + @Binding var groupLink: CreatedConnLink? @Binding var groupLinkMemberRole: GroupMemberRole var showTitle: Bool = false var creatingGroup: Bool = false var linkCreatedCb: (() -> Void)? = nil + @State private var showShortLink = true @State private var creatingLink = false @State private var alert: GroupLinkAlert? @State private var shouldCreate = true @@ -69,10 +71,10 @@ struct GroupLinkView: View { } } .frame(height: 36) - SimpleXLinkQRCode(uri: groupLink) - .id("simplex-qrcode-view-for-\(groupLink)") + SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink) + .id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))") Button { - showShareSheet(items: [simplexChatLink(groupLink)]) + showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } @@ -93,6 +95,10 @@ struct GroupLinkView: View { .frame(maxWidth: .infinity) } } + } header: { + if let groupLink, groupLink.connShortLink != nil { + ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink) + } } .alert(item: $alert) { alert in switch alert { @@ -158,8 +164,8 @@ struct GroupLinkView: View { struct GroupLinkView_Previews: PreviewProvider { static var previews: some View { - @State var groupLink: String? = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" - @State var noGroupLink: String? = nil + @State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil) + @State var noGroupLink: CreatedConnLink? = nil return Group { GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 0f64b632dc..b9f5b984e1 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -14,6 +14,7 @@ struct ContactConnectionInfo: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction @State var contactConnection: PendingContactConnection + @State private var showShortLink: Bool = true @State private var alert: CCInfoAlert? @State private var localAlias = "" @State private var showIncognitoSheet = false @@ -61,14 +62,19 @@ struct ContactConnectionInfo: View { } if contactConnection.initiated, - let connReqInv = contactConnection.connReqInv { - SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) + let connLinkInv = contactConnection.connLinkInv { + SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink) + .id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))") incognitoEnabled() - shareLinkButton(connReqInv, theme.colors.secondary) - oneTimeLinkLearnMoreButton(theme.colors.secondary) + shareLinkButton(connLinkInv, short: showShortLink) + oneTimeLinkLearnMoreButton() } else { incognitoEnabled() - oneTimeLinkLearnMoreButton(theme.colors.secondary) + oneTimeLinkLearnMoreButton() + } + } header: { + if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil { + ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink) } } footer: { sharedProfileInfo(contactConnection.incognito) @@ -167,26 +173,22 @@ struct ContactConnectionInfo: View { } } -private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View { +private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View { Button { - showShareSheet(items: [simplexChatLink(connReqInvitation)]) + showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)]) } label: { - settingsRow("square.and.arrow.up", color: secondaryColor) { - Text("Share 1-time link") - } + Label("Share 1-time link", systemImage: "square.and.arrow.up") } } -private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View { +private func oneTimeLinkLearnMoreButton() -> some View { NavigationLink { AddContactLearnMore(showTitle: false) .navigationTitle("One-time invitation link") .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.large) } label: { - settingsRow("info.circle", color: secondaryColor) { - Text("Learn more") - } + Label("Learn more", systemImage: "info.circle") } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 0fe0f2644d..87c0b80372 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -23,7 +23,7 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false - @State private var groupLink: String? + @State private var groupLink: CreatedConnLink? @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 7a7b91880c..2524b5e682 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -81,7 +81,8 @@ struct NewChatView: View { @State var selection: NewChatOption @State var showQRCodeScanner = false @State private var invitationUsed: Bool = false - @State private var connReqInvitation: String = "" + @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil) + @State private var showShortLink = true @State private var creatingConnReq = false @State var choosingProfile = false @State private var pastedLink: String = "" @@ -174,11 +175,12 @@ struct NewChatView: View { private func prepareAndInviteView() -> some View { ZStack { // ZStack is needed for views to not make transitions between each other - if connReqInvitation != "" { + if connLinkInvitation.connFullLink != "" { InviteView( invitationUsed: $invitationUsed, contactConnection: $contactConnection, - connReqInvitation: $connReqInvitation, + connLinkInvitation: $connLinkInvitation, + showShortLink: $showShortLink, choosingProfile: $choosingProfile ) } else if creatingConnReq { @@ -190,16 +192,16 @@ struct NewChatView: View { } private func createInvitation() { - if connReqInvitation == "" && contactConnection == nil && !creatingConnReq { + if connLinkInvitation.connFullLink == "" && contactConnection == nil && !creatingConnReq { creatingConnReq = true Task { _ = try? await Task.sleep(nanoseconds: 250_000000) let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get()) - if let (connReq, pcc) = r { + if let (connLink, pcc) = r { await MainActor.run { m.updateContactConnection(pcc) m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false) - connReqInvitation = connReq + connLinkInvitation = connLink contactConnection = pcc } } else { @@ -243,7 +245,8 @@ private struct InviteView: View { @EnvironmentObject var theme: AppTheme @Binding var invitationUsed: Bool @Binding var contactConnection: PendingContactConnection? - @Binding var connReqInvitation: String + @Binding var connLinkInvitation: CreatedConnLink + @Binding var showShortLink: Bool @Binding var choosingProfile: Bool @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @@ -261,7 +264,7 @@ private struct InviteView: View { NavigationLink { ActiveProfilePicker( contactConnection: $contactConnection, - connReqInvitation: $connReqInvitation, + connLinkInvitation: $connLinkInvitation, incognitoEnabled: $incognitoDefault, choosingProfile: $choosingProfile, selectedProfile: selectedProfile @@ -296,7 +299,7 @@ private struct InviteView: View { private func shareLinkView() -> some View { HStack { - let link = simplexChatLink(connReqInvitation) + let link = connLinkInvitation.simplexChatUri(short: showShortLink) linkTextView(link) Button { showShareSheet(items: [link]) @@ -310,9 +313,9 @@ private struct InviteView: View { } private func qrCodeView() -> some View { - Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) { - SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed) - .id("simplex-qrcode-view-for-\(connReqInvitation)") + Section { + SimpleXCreatedLinkQRCode(link: connLinkInvitation, short: $showShortLink, onShare: setInvitationUsed) + .id("simplex-qrcode-view-for-\(connLinkInvitation.simplexChatUri(short: showShortLink))") .padding() .background( RoundedRectangle(cornerRadius: 12, style: .continuous) @@ -322,6 +325,8 @@ private struct InviteView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } header: { + ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) } } @@ -343,7 +348,7 @@ private struct ActiveProfilePicker: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var contactConnection: PendingContactConnection? - @Binding var connReqInvitation: String + @Binding var connLinkInvitation: CreatedConnLink @Binding var incognitoEnabled: Bool @Binding var choosingProfile: Bool @State private var alert: SomeAlert? @@ -415,12 +420,11 @@ private struct ActiveProfilePicker: View { } Task { do { - if let contactConn = contactConnection, - let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) { - + if let contactConn = contactConnection { + let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) await MainActor.run { contactConnection = conn - connReqInvitation = conn.connReqInv ?? "" + connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil) incognitoEnabled = false chatModel.updateContactConnection(conn) } @@ -836,23 +840,25 @@ func sharedProfileInfo(_ incognito: Bool) -> Text { } enum PlanAndConnectAlert: Identifiable { - case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case invitationLinkConnecting(connectionLink: String) - case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) - case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) + case ownInvitationLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case invitationLinkConnecting(connectionLink: CreatedConnLink) + case ownContactAddressConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case contactAddressConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnecting(connectionLink: CreatedConnLink, groupInfo: GroupInfo?) + case error(shortOrFullLink: String, alert: Alert) var id: String { switch self { - case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" - case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" - case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" - case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" - case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" - case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" - case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" + case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink.connFullLink)" + case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink.connFullLink)" + case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink.connFullLink)" + case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink.connFullLink)" + case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink.connFullLink)" + case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink.connFullLink)" + case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink.connFullLink)" + case let .error(shortOrFullLink, alert): return "error \(shortOrFullLink)" } } } @@ -935,21 +941,22 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: ( dismissButton: .default(Text("OK")) { cleanup?() } ) } + case let .error(_, alert): return alert } } enum PlanAndConnectActionSheet: Identifiable { - case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) - case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case askCurrentOrIncognitoProfile(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) + case askCurrentOrIncognitoProfileDestructive(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, title: LocalizedStringKey) case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact) - case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) + case ownGroupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) var id: String { switch self { - case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" - case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" + case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink.connFullLink)" + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink.connFullLink)" case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)" - case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" + case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink.connFullLink)" } } } @@ -1008,7 +1015,7 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool } func planAndConnect( - _ connectionLink: String, + _ shortOrFullLink: String, showAlert: @escaping (PlanAndConnectAlert) -> Void, showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, dismiss: Bool, @@ -1018,8 +1025,8 @@ func planAndConnect( filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { Task { - do { - let connectionPlan = try await apiConnectPlan(connReq: connectionLink) + let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink) + if let (connectionLink, connectionPlan) = result { switch connectionPlan { case let .invitationLink(ilp): switch ilp { @@ -1028,32 +1035,40 @@ func planAndConnect( if let incognito = incognito { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + await MainActor.run { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + } } case .ownLink: logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + await MainActor.run { + if let incognito = incognito { + showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + } } case let .connecting(contact_): logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") - if let contact = contact_ { - if let f = filterKnownContact { - f(contact) + await MainActor.run { + if let contact = contact_ { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) } - } else { - showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) } case let .known(contact): logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownContact { - f(contact) - } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } } } case let .contactAddress(cap): @@ -1063,83 +1078,109 @@ func planAndConnect( if let incognito = incognito { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + await MainActor.run { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + } } case .ownLink: logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + await MainActor.run { + if let incognito = incognito { + showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + } } case .connectingConfirmReconnect: logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + await MainActor.run { + if let incognito = incognito { + showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + } } case let .connectingProhibit(contact): logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownContact { - f(contact) - } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } } case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownContact { - f(contact) - } else { - openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } } case let .contactViaAddress(contact): logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")") if let incognito = incognito { connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { - showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) + await MainActor.run { + showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) + } } } case let .groupLink(glp): switch glp { case .ok: - if let incognito = incognito { - showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + await MainActor.run { + if let incognito = incognito { + showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + } } case let .ownLink(groupInfo): logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownGroup { - f(groupInfo) + await MainActor.run { + if let f = filterKnownGroup { + f(groupInfo) + } + showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) } - showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) case .connectingConfirmReconnect: logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") - if let incognito = incognito { - showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) - } else { - showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + await MainActor.run { + if let incognito = incognito { + showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + } } case let .connectingProhibit(groupInfo_): logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") - showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + await MainActor.run { + showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + } case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") - if let f = filterKnownGroup { - f(groupInfo) - } else { - openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + await MainActor.run { + if let f = filterKnownGroup { + f(groupInfo) + } else { + openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + } } } + case let .error(chatError): + logger.debug("planAndConnect, .error \(chatErrorString(chatError))") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) + } } - } catch { - logger.debug("planAndConnect, plan error") - if let incognito = incognito { - connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup) - } else { - showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) + } else if let alert { + await MainActor.run { + showAlert(.error(shortOrFullLink: shortOrFullLink, alert: alert)) } } } @@ -1161,22 +1202,22 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn } private func connectViaLink( - _ connectionLink: String, + _ connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? ) { Task { - if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) { + if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) { await MainActor.run { ChatModel.shared.updateContactConnection(pcc) } let crt: ConnReqType - if let plan = connectionPlan { - crt = planToConnReqType(plan) + crt = if let plan = connectionPlan { + planToConnReqType(plan) ?? connReqType } else { - crt = connReqType + connReqType } DispatchQueue.main.async { if dismiss { @@ -1199,43 +1240,35 @@ private func connectViaLink( } func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { - Task { - let m = ChatModel.shared - if let c = m.getContactChat(contact.contactId) { - DispatchQueue.main.async { - if dismiss { - dismissAllSheets(animated: true) { - ItemsModel.shared.loadOpenChat(c.id) { - showAlreadyExistsAlert?() - } - } - } else { - ItemsModel.shared.loadOpenChat(c.id) { - showAlreadyExistsAlert?() - } + let m = ChatModel.shared + if let c = m.getContactChat(contact.contactId) { + if dismiss { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(c.id) { + showAlreadyExistsAlert?() } } + } else { + ItemsModel.shared.loadOpenChat(c.id) { + showAlreadyExistsAlert?() + } } } } func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { - Task { - let m = ChatModel.shared - if let g = m.getGroupChat(groupInfo.groupId) { - DispatchQueue.main.async { - if dismiss { - dismissAllSheets(animated: true) { - ItemsModel.shared.loadOpenChat(g.id) { - showAlreadyExistsAlert?() - } - } - } else { - ItemsModel.shared.loadOpenChat(g.id) { - showAlreadyExistsAlert?() - } + let m = ChatModel.shared + if let g = m.getGroupChat(groupInfo.groupId) { + if dismiss { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(g.id) { + showAlreadyExistsAlert?() } } + } else { + ItemsModel.shared.loadOpenChat(g.id) { + showAlreadyExistsAlert?() + } } } } @@ -1273,11 +1306,12 @@ enum ConnReqType: Equatable { } } -private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { +private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? { switch connectionPlan { - case .invitationLink: return .invitation - case .contactAddress: return .contact - case .groupLink: return .groupLink + case .invitationLink: .invitation + case .contactAddress: .contact + case .groupLink: .groupLink + case .error: nil } } diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index bc1dc4b5bc..453149198b 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -8,6 +8,7 @@ import SwiftUI import CoreImage.CIFilterBuiltins +import SimpleXChat struct MutableQRCode: View { @Binding var uri: String @@ -20,6 +21,16 @@ struct MutableQRCode: View { } } +struct SimpleXCreatedLinkQRCode: View { + let link: CreatedConnLink + @Binding var short: Bool + var onShare: (() -> Void)? = nil + + var body: some View { + QRCode(uri: link.simplexChatUri(short: short), onShare: onShare) + } +} + struct SimpleXLinkQRCode: View { let uri: String var withLogo: Bool = true @@ -31,12 +42,6 @@ struct SimpleXLinkQRCode: View { } } -func simplexChatLink(_ uri: String) -> String { - uri.starts(with: "simplex:/") - ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") - : uri -} - struct QRCode: View { let uri: String var withLogo: Bool = true diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index befb34b318..a2f5db7f03 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View { Spacer() if let userAddress = m.userAddress { - SimpleXLinkQRCode(uri: userAddress.connReqContact) + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: Binding.constant(false)) .frame(maxHeight: g.size.width) shareQRCodeButton(userAddress) .frame(maxWidth: .infinity) @@ -77,9 +77,9 @@ struct CreateSimpleXAddress: View { progressIndicator = true Task { do { - let connReqContact = try await apiCreateUserAddress() + let connLinkContact = try await apiCreateUserAddress(short: false) DispatchQueue.main.async { - m.userAddress = UserContactLink(connReqContact: connReqContact) + m.userAddress = UserContactLink(connLinkContact: connLinkContact) } await MainActor.run { progressIndicator = false } } catch let error { @@ -121,7 +121,7 @@ struct CreateSimpleXAddress: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) + showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))]) } label: { Label("Share", systemImage: "square.and.arrow.up") } @@ -189,7 +189,7 @@ struct SendAddressMailView: View { let messageBody = String(format: NSLocalizedString("""

Hi!

Connect to me via SimpleX Chat

- """, comment: "email text"), simplexChatLink(userAddress.connReqContact)) + """, comment: "email text"), simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))) MailView( isShowing: self.$showMailView, result: $mailViewResult, diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 0b9d1ef76c..1a17b9d661 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -20,6 +20,8 @@ struct PrivacySettings: View { @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @AppStorage(DEFAULT_PRIVACY_SHORT_LINKS) private var shortSimplexLinks = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var currentLAMode = privacyLocalAuthModeDefault.get() @@ -111,6 +113,11 @@ struct PrivacySettings: View { .onChange(of: simplexLinkMode) { mode in privacySimplexLinkModeDefault.set(mode) } + if developerTools { + settingsRow("link.badge.plus", color: theme.colors.secondary) { + Toggle("Use short links (BETA)", isOn: $shortSimplexLinks) + } + } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 80e2a537da..961cad128f 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -33,6 +33,7 @@ let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" +let DEFAULT_PRIVACY_SHORT_LINKS = "privacyShortLinks" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius" @@ -99,6 +100,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue, DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true, DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true, + DEFAULT_PRIVACY_SHORT_LINKS: false, DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false, DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0, diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 7965215b49..4813edf96c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -8,7 +8,7 @@ import SwiftUI import MessageUI -import SimpleXChat +@preconcurrency import SimpleXChat struct UserAddressView: View { @Environment(\.dismiss) var dismiss: DismissAction @@ -16,6 +16,7 @@ struct UserAddressView: View { @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false @State var autoCreate = false + @State private var showShortLink = true @State private var aas = AutoAcceptState() @State private var savedAAS = AutoAcceptState() @State private var showMailView = false @@ -135,8 +136,8 @@ struct UserAddressView: View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { Section { - SimpleXLinkQRCode(uri: userAddress.connReqContact) - .id("simplex-contact-address-qrcode-\(userAddress.connReqContact)") + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink) + .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))") shareQRCodeButton(userAddress) // if MFMailComposeViewController.canSendMail() { // shareViaEmailButton(userAddress) @@ -153,8 +154,7 @@ struct UserAddressView: View { } addressSettingsButton(userAddress) } header: { - Text("For social media") - .foregroundColor(theme.colors.secondary) + ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) } footer: { if aas.business { Text("Add your team members to the conversations.") @@ -193,9 +193,10 @@ struct UserAddressView: View { progressIndicator = true Task { do { - let connReqContact = try await apiCreateUserAddress() + let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) + let connLinkContact = try await apiCreateUserAddress(short: short) DispatchQueue.main.async { - chatModel.userAddress = UserContactLink(connReqContact: connReqContact) + chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact) alert = .shareOnCreate progressIndicator = false } @@ -231,7 +232,7 @@ struct UserAddressView: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) + showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: showShortLink))]) } label: { settingsRow("square.and.arrow.up", color: theme.colors.secondary) { Text("Share address") @@ -294,6 +295,28 @@ struct UserAddressView: View { } } +struct ToggleShortLinkHeader: View { + @EnvironmentObject var theme: AppTheme + let text: Text + var link: CreatedConnLink + @Binding var short: Bool + + var body: some View { + if link.connShortLink == nil { + text.foregroundColor(theme.colors.secondary) + } else { + HStack { + text.foregroundColor(theme.colors.secondary) + Spacer() + Text(short ? "Full link" : "Short link") + .textCase(.none) + .foregroundColor(theme.colors.primary) + .onTapGesture { short.toggle() } + } + } + } +} + private struct AutoAcceptState: Equatable { var enable = false var incognito = false @@ -542,7 +565,7 @@ private func saveAAS(_ aas: Binding, _ savedAAS: Bindingapplinks:simplex.chat applinks:www.simplex.chat applinks:simplex.chat?mode=developer + applinks:*.simplex.im + applinks:*.simplex.im?mode=developer + applinks:*.simplexonflux.com + applinks:*.simplexonflux.com?mode=developer com.apple.security.application-groups diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 366bfd167a..096d65cd64 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -174,8 +174,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -531,8 +531,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -688,8 +688,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -774,8 +774,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.1.1-AtbMdJukKDu9yojMiV9pEU.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.2.0-m8oPCo81q3CiwhMXvT868.a */, ); path = Libraries; sourceTree = ""; @@ -1963,7 +1963,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1988,7 +1988,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2013,7 +2013,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2038,7 +2038,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2055,11 +2055,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2075,11 +2075,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2100,7 +2100,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2115,7 +2115,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2137,7 +2137,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2152,7 +2152,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2174,7 +2174,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2200,7 +2200,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2225,7 +2225,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2251,7 +2251,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2310,7 +2310,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2325,7 +2325,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.1; + MARKETING_VERSION = 6.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index e439cd337b..869dffea31 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -324,7 +324,7 @@ public func responseError(_ err: Error) -> String { } } -func chatErrorString(_ err: ChatError) -> String { +public func chatErrorString(_ err: ChatError) -> String { if case let .invalidJSON(json) = err { return json } return String(describing: err) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 6db0478ab3..a9de0df01b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -77,7 +77,7 @@ public enum ChatCommand { case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) - case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole) + case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole, short: Bool) case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) @@ -116,11 +116,11 @@ public enum ChatCommand { case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case apiAddContact(userId: Int64, incognito: Bool) + case apiAddContact(userId: Int64, short: Bool, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiChangeConnectionUser(connId: Int64, userId: Int64) - case apiConnectPlan(userId: Int64, connReq: String) - case apiConnect(userId: Int64, incognito: Bool, connReq: String) + case apiConnectPlan(userId: Int64, connLink: String) + case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink) case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) case apiClearChat(type: ChatType, id: Int64) @@ -132,7 +132,7 @@ public enum ChatCommand { case apiSetConnectionAlias(connId: Int64, localAlias: String) case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) - case apiCreateMyAddress(userId: Int64) + case apiCreateMyAddress(userId: Int64, short: Bool) case apiDeleteMyAddress(userId: Int64) case apiShowMyAddress(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) @@ -256,7 +256,7 @@ public enum ChatCommand { case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" - case let .apiCreateGroupLink(groupId, memberRole): return "/_create link #\(groupId) \(memberRole)" + case let .apiCreateGroupLink(groupId, memberRole, short): return "/_create link #\(groupId) \(memberRole) short=\(onOff(short))" case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" @@ -305,11 +305,11 @@ public enum ChatCommand { case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" + case let .apiAddContact(userId, short, incognito): return "/_connect \(userId) short=\(onOff(short)) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" - case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" - case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" + case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" + case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" @@ -321,7 +321,7 @@ public enum ChatCommand { case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" - case let .apiCreateMyAddress(userId): return "/_address \(userId)" + case let .apiCreateMyAddress(userId, short): return "/_address \(userId) short=\(onOff(short))" case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" case let .apiShowMyAddress(userId): return "/_show_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" @@ -629,10 +629,10 @@ public enum ChatResponse: Decodable, Error { case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64]) - case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) + case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) - case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) + case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) @@ -649,7 +649,7 @@ public enum ChatResponse: Decodable, Error { case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) case userContactLink(user: User, contactLink: UserContactLink) case userContactLinkUpdated(user: User, contactLink: UserContactLink) - case userContactLinkCreated(user: User, connReqContact: String) + case userContactLinkCreated(user: User, connLinkContact: CreatedConnLink) case userContactLinkDeleted(user: User) case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case contactConnecting(user: UserRef, contact: Contact) @@ -702,8 +702,8 @@ public enum ChatResponse: Decodable, Error { case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupRemoved(user: UserRef, groupInfo: GroupInfo) // unused case groupUpdated(user: UserRef, toGroup: GroupInfo) - case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) - case groupLink(user: UserRef, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) + case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) + case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) @@ -989,10 +989,10 @@ public enum ChatResponse: Decodable, Error { case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") - case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)") + case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) - case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\newUserId: \(String(describing: newUser.userId))") - case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) + case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") + case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) @@ -1009,7 +1009,7 @@ public enum ChatResponse: Decodable, Error { case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkCreated(u, connReq): return withUser(u, connReq) + case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) case .userContactLinkDeleted: return noDetails case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) @@ -1069,8 +1069,8 @@ public enum ChatResponse: Decodable, Error { case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkCreated(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") - case let .groupLink(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") + case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") @@ -1173,10 +1173,31 @@ public enum ChatDeleteMode: Codable { } } +public struct CreatedConnLink: Decodable, Hashable { + public var connFullLink: String + public var connShortLink: String? + + public init(connFullLink: String, connShortLink: String?) { + self.connFullLink = connFullLink + self.connShortLink = connShortLink + } + + public func simplexChatUri(short: Bool = true) -> String { + short ? (connShortLink ?? simplexChatLink(connFullLink)) : simplexChatLink(connFullLink) + } +} + +public func simplexChatLink(_ uri: String) -> String { + uri.starts(with: "simplex:/") + ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") + : uri +} + public enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) case groupLink(groupLinkPlan: GroupLinkPlan) + case error(chatError: ChatError) } public enum InvitationLinkPlan: Decodable, Hashable { @@ -2183,16 +2204,16 @@ public enum RatchetSyncState: String, Decodable { } public struct UserContactLink: Decodable, Hashable { - public var connReqContact: String + public var connLinkContact: CreatedConnLink public var autoAccept: AutoAccept? - public init(connReqContact: String, autoAccept: AutoAccept? = nil) { - self.connReqContact = connReqContact + public init(connLinkContact: CreatedConnLink, autoAccept: AutoAccept? = nil) { + self.connLinkContact = connLinkContact self.autoAccept = autoAccept } var responseDetails: String { - "connReqContact: \(connReqContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" + "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" } } @@ -2404,8 +2425,8 @@ public enum ChatErrorType: Decodable, Hashable { case chatNotStarted case chatNotStopped case chatStoreChanged - case connectionPlan(connectionPlan: ConnectionPlan) case invalidConnReq + case unsupportedConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) case contactNotActive(contact: Contact) @@ -2521,6 +2542,7 @@ public enum StoreError: Decodable, Hashable { case hostMemberIdNotFound(groupId: Int64) case contactNotFoundByFileId(fileId: Int64) case noGroupSndStatus(itemId: Int64, groupMemberId: Int64) + case dBException(message: String) } public enum DatabaseError: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 51feb623e2..0c47442987 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1853,7 +1853,7 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { public var viaContactUri: Bool public var groupLinkId: String? public var customUserProfileId: Int64? - public var connReqInv: String? + public var connLinkInv: CreatedConnLink? public var localAlias: String var createdAt: Date public var updatedAt: Date @@ -4063,12 +4063,14 @@ public enum SimplexLinkType: String, Decodable, Hashable { case contact case invitation case group + case channel public var description: String { switch self { case .contact: return NSLocalizedString("SimpleX contact address", comment: "simplex link type") case .invitation: return NSLocalizedString("SimpleX one-time invitation", comment: "simplex link type") case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type") + case .channel: return NSLocalizedString("SimpleX channel link", comment: "simplex link type") } } } diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 48dfba11cc..0470977bcd 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -77,8 +77,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1c8209334d..5545595dc6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -24,7 +24,6 @@ import chat.simplex.app.views.call.CallActivity import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -33,7 +32,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* -import kotlinx.coroutines.flow.map import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -94,7 +92,7 @@ class SimplexApp: Application(), LifecycleEventObserver { Lifecycle.Event.ON_START -> { isAppOnForeground = true if (chatModel.chatRunning.value == true) { - withChats { + withContext(Dispatchers.Main) { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId())) @@ -107,7 +105,7 @@ class SimplexApp: Application(), LifecycleEventObserver { /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) } - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index a1698ae28a..f6066d1624 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -14,12 +14,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.clear import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 import chat.simplex.res.MR +import kotlinx.coroutines.* actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() @@ -76,13 +75,10 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { withApi { - withChats { + withContext(Dispatchers.Main) { // Since no modals are open, the problem is probably in ChatView chatModel.chatId.value = null - chatItems.clearAndNotify() - } - withChats { - chatItems.clearAndNotify() + chatModel.chatsContext.chatItems.clearAndNotify() } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 600804a763..d88a450fd1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, contentTag = null, onComposed = onComposed) + ChatView(chatsCtx = chatModel.chatsContext, currentChatId, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId, contentTag = null) {} + else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 13cdc9f19a..7e5eed3c42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -67,7 +67,7 @@ object ChatModel { val chatId = mutableStateOf(null) val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) - val reportsChatsContext = ChatsContext(MsgContentTag.Report) + val secondaryChatsContext = mutableStateOf(null) // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions val chats: State> = chatsContext.chats // rhId, chatId @@ -170,36 +170,6 @@ object ChatModel { // return true if you handled the click var centerPanelBackgroundClickHandler: (() -> Boolean)? = null - fun chatsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { - null -> chatsContext.chats - MsgContentTag.Report -> reportsChatsContext.chats - else -> TODO() - } - - fun chatItemsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { - null -> chatsContext.chatItems - MsgContentTag.Report -> reportsChatsContext.chatItems - else -> TODO() - } - - fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) { - null -> chatsContext.chatState - MsgContentTag.Report -> reportsChatsContext.chatState - else -> TODO() - } - - fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) { - null -> chatsContext.chatItemsChangesListener - MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener - else -> TODO() - } - - fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) { - null -> chatsContext.chatItemsChangesListener = listener - MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener - else -> TODO() - } - fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -324,31 +294,15 @@ object ChatModel { } } - // running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread - suspend fun withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { - when { - contentTag == null -> chatsContext.action() - contentTag == MsgContentTag.Report -> reportsChatsContext.action() - else -> TODO() - } - } - - suspend fun withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) { - if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { - reportsChatsContext.action() - } - } - - class ChatsContext(private val contentTag: MsgContentTag?) { + class ChatsContext(val contentTag: MsgContentTag?) { val chats = mutableStateOf(SnapshotStateList()) - /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. + /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on. * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. - * If you use api call to get the items, use just [add] instead of [addAndNotify]. + * If you use api call to get the items, use just [add] instead of [addToChatItems]. * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ val chatItems = mutableStateOf(SnapshotStateList()) val chatItemStatuses = mutableMapOf() // set listener here that will be notified on every add/delete of a chat item - var chatItemsChangesListener: ChatItemsChangesListener? = null val chatState = ActiveChatState() fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null @@ -440,6 +394,26 @@ object ChatModel { addChat(chat) } } + + fun addToChatItems(index: Int, elem: ChatItem) { + chatItems.value = SnapshotStateList().apply { addAll(chatItems.value); add(index, elem); chatState.itemAdded(elem.id to elem.isRcvNew) } + } + + fun addToChatItems(elem: ChatItem) { + chatItems.value = SnapshotStateList().apply { addAll(chatItems.value); add(elem); chatState.itemAdded(elem.id to elem.isRcvNew) } + } + + fun removeLastChatItems() { + val removed: Triple + chatItems.value = SnapshotStateList().apply { + addAll(chatItems.value) + val remIndex = lastIndex + val rem = removeLast() + removed = Triple(rem.id, remIndex, rem.isRcvNew) + } + chatState.itemsRemoved(listOf(removed), chatItems.value) + } + suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { // mark chat non deleted if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { @@ -493,9 +467,9 @@ object ChatModel { // Prevent situation when chat item already in the list received from backend if (chatItems.value.none { it.id == cItem.id }) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag) + addToChatItems(kotlin.math.max(0, chatItems.value.lastIndex), cItem) } else { - chatItems.addAndNotify(cItem, contentTag) + addToChatItems(cItem) } } } @@ -540,7 +514,7 @@ object ChatModel { } else { cItem } - chatItems.addAndNotify(ci, contentTag) + addToChatItems(ci) true } } else { @@ -647,9 +621,10 @@ object ChatModel { } } - val popChatCollector = PopChatCollector(contentTag) + val popChatCollector = PopChatCollector(this) - class PopChatCollector(contentTag: MsgContentTag?) { + // TODO [contexts] no reason for this to be nested? + class PopChatCollector(chatsCtx: ChatsContext) { private val subject = MutableSharedFlow() private var remoteHostId: Long? = null private val chatsToPop = mutableMapOf() @@ -659,8 +634,8 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats(contentTag) { - chats.replaceAll(popCollectedChats()) + withContext(Dispatchers.Main) { + chatsCtx.chats.replaceAll(popCollectedChats()) } } } @@ -748,7 +723,7 @@ object ChatModel { } i-- } - chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) + chatState.itemsRead(if (itemIds != null) markedReadIds else null, items) } return markedRead to mentionsMarkedRead } @@ -960,17 +935,17 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) - withChats { - chatItems.addAndNotify(cItem, contentTag = null) + withContext(Dispatchers.Main) { + chatsContext.addToChatItems(cItem) } return cItem } fun removeLiveDummy() { - if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + if (chatsContext.chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { withApi { - withChats { - chatItems.removeLastAndNotify(contentTag = null) + withContext(Dispatchers.Main) { + chatsContext.removeLastChatItems() } } } @@ -1042,9 +1017,9 @@ object ChatModel { fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { withApi { - withChats { + withContext(Dispatchers.Main) { showingInvitation.value = null - chatItems.clearAndNotify() + chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = withId } } @@ -1055,9 +1030,9 @@ object ChatModel { fun dismissConnReqView(id: String) = withApi { if (id == showingInvitation.value?.connId) { - withChats { + withContext(Dispatchers.Main) { showingInvitation.value = null - chatItems.clearAndNotify() + chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = null } // Close NewChatView @@ -1108,18 +1083,9 @@ object ChatModel { fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } -interface ChatItemsChangesListener { - // pass null itemIds if the whole chat now read - fun read(itemIds: Set?, newItems: List) - fun added(item: Pair, index: Int) - // itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not) - fun removed(itemIds: List>, newItems: List) - fun cleared() -} - data class ShowingInvitation( val connId: String, - val connReq: String, + val connLink: CreatedConnLink, val connChatUsed: Boolean, val conn: PendingContactConnection ) @@ -2239,7 +2205,7 @@ class PendingContactConnection( val viaContactUri: Boolean, val groupLinkId: String? = null, val customUserProfileId: Long? = null, - val connReqInv: String? = null, + val connLinkInv: CreatedConnLink? = null, override val localAlias: String, override val createdAt: Instant, override val updatedAt: Instant @@ -2738,10 +2704,6 @@ fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) { - value = SnapshotStateList().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) } -} - fun MutableState>.add(elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(elem) } } @@ -2749,11 +2711,6 @@ fun MutableState>.add(elem: Chat) { // For some reason, Kotlin version crashes if the list is empty fun MutableList.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate) -// Adds item to chatItems and notifies a listener about newly added item -fun MutableState>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) { - value = SnapshotStateList().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) } -} - fun MutableState>.addAll(index: Int, elems: List) { value = SnapshotStateList().apply { addAll(value); addAll(index, elems) } } @@ -2766,6 +2723,7 @@ fun MutableState>.removeAll(block: (Chat) -> Boolean) { value = SnapshotStateList().apply { addAll(value); removeAll(block) } } +// TODO [contexts] operates with both contexts? // Removes item(s) from chatItems and notifies a listener about removed item(s) fun MutableState>.removeAllAndNotify(block: (ChatItem) -> Boolean) { val toRemove = ArrayList>() @@ -2780,8 +2738,8 @@ fun MutableState>.removeAllAndNotify(block: (ChatIte } } if (toRemove.isNotEmpty()) { - chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value) - chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value) + chatModel.chatsContext.chatState.itemsRemoved(toRemove, value) + chatModel.secondaryChatsContext.value?.chatState?.itemsRemoved(toRemove, value) } } @@ -2793,17 +2751,6 @@ fun MutableState>.removeAt(index: Int): Chat { return res } -fun MutableState>.removeLastAndNotify(contentTag: MsgContentTag?) { - val removed: Triple - value = SnapshotStateList().apply { - addAll(value) - val remIndex = lastIndex - val rem = removeLast() - removed = Triple(rem.id, remIndex, rem.isRcvNew) - } - chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value) -} - fun MutableState>.replaceAll(elems: List) { value = SnapshotStateList().apply { addAll(elems) } } @@ -2812,11 +2759,12 @@ fun MutableState>.clear() { value = SnapshotStateList() } +// TODO [contexts] operates with both contexts? // Removes all chatItems and notifies a listener about it fun MutableState>.clearAndNotify() { value = SnapshotStateList() - chatModel.chatsContext.chatItemsChangesListener?.cleared() - chatModel.reportsChatsContext.chatItemsChangesListener?.cleared() + chatModel.chatsContext.chatState.clear() + chatModel.secondaryChatsContext.value?.chatState?.clear() } fun State>.asReversed(): MutableList = value.asReversed() @@ -4006,12 +3954,14 @@ sealed class Format { enum class SimplexLinkType(val linkType: String) { contact("contact"), invitation("invitation"), - group("group"); + group("group"), + channel("channel"); val description: String get() = generalGetString(when (this) { contact -> MR.strings.simplex_link_contact invitation -> MR.strings.simplex_link_invitation group -> MR.strings.simplex_link_group + channel -> MR.strings.simplex_link_channel }) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b04a08e0e1..a557cf93cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -17,8 +19,6 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.MsgContent.MCUnknown import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -121,6 +121,7 @@ class AppPreferences { ) val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true) + val privacyShortLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SHORT_LINKS, false) val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true) val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true) @@ -380,6 +381,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" + private const val SHARED_PREFS_PRIVACY_SHORT_LINKS = "PrivacyShortLinks" private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles" private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays" @@ -544,9 +546,9 @@ object ChatController { } Log.d(TAG, "startChat: started") } else { - withChats { + withContext(Dispatchers.Main) { val chats = apiGetChats(null) - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } Log.d(TAG, "startChat: running") } @@ -627,9 +629,9 @@ object ChatController { val hasUser = chatModel.currentUser.value != null chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None - withChats { + withContext(Dispatchers.Main) { val chats = apiGetChats(rhId) - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() chatModel.activeChatTagFilter.value = null @@ -1366,11 +1368,12 @@ object ChatController { - suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> { + suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> { val userId = try { currentUserId("apiAddContact") } catch (e: Exception) { return null to null } - val r = sendCmd(rh, CC.APIAddContact(userId, incognito)) + val short = appPrefs.privacyShortLinks.get() + val r = sendCmd(rh, CC.APIAddContact(userId, short = short, incognito = incognito)) return when (r) { - is CR.Invitation -> (r.connReqInvitation to r.connection) to null + is CR.Invitation -> (r.connLinkInvitation to r.connection) to null else -> { if (!(networkErrorAlert(r))) { return null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } @@ -1408,34 +1411,45 @@ object ChatController { } } - suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { + suspend fun apiConnectPlan(rh: Long?, connLink: String): Pair? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmd(rh, CC.APIConnectPlan(userId, connReq)) - if (r is CR.CRConnectionPlan) return r.connectionPlan - Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") + val r = sendCmd(rh, CC.APIConnectPlan(userId, connLink)) + if (r is CR.CRConnectionPlan) return r.connLink to r.connectionPlan + apiConnectResponseAlert(r) return null } - suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connLink: CreatedConnLink): PendingContactConnection? { val userId = try { currentUserId("apiConnect") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) + val r = sendCmd(rh, CC.APIConnect(userId, incognito, connLink)) when { r is CR.SentConfirmation -> return r.connection r is CR.SentInvitation -> return r.connection - r is CR.ContactAlreadyExists -> { + r is CR.ContactAlreadyExists -> AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) ) - return null - } + else -> apiConnectResponseAlert(r) + } + return null + } + + private fun apiConnectResponseAlert(r: CR) { + when { r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) ) - return null + } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat + && r.chatError.errorType is ChatErrorType.UnsupportedConnReq -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.unsupported_connection_link), + generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade) + ) } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -1444,7 +1458,6 @@ object ChatController { generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth_desc) ) - return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -1453,7 +1466,6 @@ object ChatController { generalGetString(MR.strings.connection_error_blocked), generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), ) - return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -1462,13 +1474,11 @@ object ChatController { generalGetString(MR.strings.connection_error_quota), generalGetString(MR.strings.connection_error_quota_desc) ) - return null } else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r) } - return null } } } @@ -1490,8 +1500,8 @@ object ChatController { suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { val cInfo = chat.chatInfo if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) { - withChats { - removeChat(chat.remoteHostId, cInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(chat.remoteHostId, cInfo.id) } } } @@ -1539,11 +1549,11 @@ object ChatController { withBGApi { val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId) if (updatedChatInfo != null) { - withChats { - clearChat(chat.remoteHostId, updatedChatInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.clearChat(chat.remoteHostId, updatedChatInfo) } - withChats(MsgContentTag.Report) { - clearChat(chat.remoteHostId, updatedChatInfo) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.clearChat(chat.remoteHostId, updatedChatInfo) } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() @@ -1621,11 +1631,11 @@ object ChatController { return false } - suspend fun apiCreateUserAddress(rh: Long?): String? { + suspend fun apiCreateUserAddress(rh: Long?, short: Boolean): CreatedConnLink? { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } - val r = sendCmd(rh, CC.ApiCreateMyAddress(userId)) + val r = sendCmd(rh, CC.ApiCreateMyAddress(userId, short)) return when (r) { - is CR.UserContactLinkCreated -> r.connReqContact + is CR.UserContactLinkCreated -> r.connLinkContact else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) @@ -1975,12 +1985,14 @@ object ChatController { val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) when (r) { is CR.UserAcceptedGroupSent -> - withChats { - updateGroup(rh, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rh, r.groupInfo) } is CR.ChatCmdError -> { val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { withChats { removeChat(rh, "#$groupId") } } } + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { + withContext(Dispatchers.Main) { chatModel.chatsContext.removeChat(rh, "#$groupId") } } + } if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) { deleteGroup() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired)) @@ -2060,9 +2072,10 @@ object ChatController { } } - suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole))) { - is CR.GroupLinkCreated -> r.connReqContact to r.memberRole + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + val short = appPrefs.privacyShortLinks.get() + return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole, short))) { + is CR.GroupLinkCreated -> r.connLinkContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) @@ -2072,9 +2085,9 @@ object ChatController { } } - suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { return when (val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole))) { - is CR.GroupLink -> r.connReqContact to r.memberRole + is CR.GroupLink -> r.connLinkContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiGroupLinkMemberRole", generalGetString(MR.strings.error_updating_link_for_group), r) @@ -2096,9 +2109,9 @@ object ChatController { } } - suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { + suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { return when (val r = sendCmd(rh, CC.APIGetGroupLink(groupId))) { - is CR.GroupLink -> r.connReqContact to r.memberRole + is CR.GroupLink -> r.connLinkContact to r.memberRole else -> { Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") null @@ -2134,8 +2147,8 @@ object ChatController { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) if (toContact != null) { - withChats { - updateContact(rh, toContact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rh, toContact) } } } @@ -2406,19 +2419,19 @@ object ChatController { when (r) { is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } @@ -2429,24 +2442,24 @@ object ChatController { } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } } is CR.ContactSndReady -> { if (active(r.user) && r.contact.directOrUsed) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } @@ -2456,11 +2469,11 @@ object ChatController { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { - withChats { - if (hasChat(rhId, contactRequest.id)) { - updateChatInfo(rhId, cInfo) + withContext(Dispatchers.Main) { + if (chatModel.chatsContext.hasChat(rhId, contactRequest.id)) { + chatModel.chatsContext.updateChatInfo(rhId, cInfo) } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) + chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) } } } @@ -2469,18 +2482,18 @@ object ChatController { is CR.ContactUpdated -> { if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) - withChats { - updateChatInfo(rhId, cInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatInfo(rhId, cInfo) } } } is CR.GroupMemberUpdated -> { if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.toMember) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.toMember) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.toMember) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.toMember) } } } @@ -2489,8 +2502,8 @@ object ChatController { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } - withChats { - removeChat(rhId, r.mergedContact.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, r.mergedContact.id) } } } @@ -2501,8 +2514,8 @@ object ChatController { is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { - withChats { - updateContact(rhId, sub.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, sub.contact) } } val err = sub.contactError @@ -2528,20 +2541,20 @@ object ChatController { val cInfo = chatItem.chatInfo val cItem = chatItem.chatItem if (active(r.user)) { - withChats { - addChatItem(rhId, cInfo, cItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.addChatItem(rhId, cInfo, cItem) if (cItem.isActiveReport) { - increaseGroupReportsCounter(rhId, cInfo.id) + chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (cItem.isReport) { - addChatItem(rhId, cInfo, cItem) + chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) } } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { - withChats { - increaseUnreadCounter(rhId, r.user) + withContext(Dispatchers.Main) { + chatModel.chatsContext.increaseUnreadCounter(rhId, r.user) } } val file = cItem.file @@ -2562,12 +2575,12 @@ object ChatController { val cInfo = chatItem.chatInfo val cItem = chatItem.chatItem if (!cItem.isDeletedContent && active(r.user)) { - withChats { - updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (cItem.isReport) { - updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } } @@ -2576,12 +2589,12 @@ object ChatController { chatItemUpdateNotify(rhId, r.user, r.chatItem) is CR.ChatItemReaction -> { if (active(r.user)) { - withChats { - updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (r.reaction.chatReaction.chatItem.isReport) { - updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } } } @@ -2590,8 +2603,8 @@ object ChatController { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled(deletedChatItem.chatItem)) { - withChats { - decreaseUnreadCounter(rhId, r.user) + withContext(Dispatchers.Main) { + chatModel.chatsContext.decreaseUnreadCounter(rhId, r.user) } } } @@ -2614,22 +2627,22 @@ object ChatController { generalGetString(if (toChatItem != null) MR.strings.marked_deleted_description else MR.strings.deleted_description) ) } - withChats { + withContext(Dispatchers.Main) { if (toChatItem == null) { - removeChatItem(rhId, cInfo, cItem) + chatModel.chatsContext.removeChatItem(rhId, cInfo, cItem) } else { - upsertChatItem(rhId, cInfo, toChatItem.chatItem) + chatModel.chatsContext.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } if (cItem.isActiveReport) { - decreaseGroupReportsCounter(rhId, cInfo.id) + chatModel.chatsContext.decreaseGroupReportsCounter(rhId, cInfo.id) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (cItem.isReport) { if (toChatItem == null) { - removeChatItem(rhId, cInfo, cItem) + chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) } else { - upsertChatItem(rhId, cInfo, toChatItem.chatItem) + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } } @@ -2640,9 +2653,9 @@ object ChatController { } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { - withChats { + withContext(Dispatchers.Main) { // update so that repeat group invitations are not duplicated - updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } // TODO NtfManager.shared.notifyGroupInvitation } @@ -2650,137 +2663,137 @@ object ChatController { is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) val conn = r.hostContact?.activeConn if (conn != null) { chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}") - removeChat(rhId, conn.id) + chatModel.chatsContext.removeChat(rhId, conn.id) } } } is CR.GroupLinkConnecting -> { if (!active(r.user)) return - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") - removeChat(rhId, hostConn.id) + chatModel.chatsContext.removeChat(rhId, hostConn.id) } } } is CR.BusinessLinkConnecting -> { if (!active(r.user)) return - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } if (chatModel.chatId.value == r.fromContact.id) { openGroupChat(rhId, r.groupInfo.groupId) } - withChats { - removeChat(rhId, r.fromContact.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, r.fromContact.id) } } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) if (r.withMessages) { - removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + chatModel.chatsContext.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (r.withMessages) { - removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) + chatModel.secondaryChatsContext.value?.removeMemberItems(rhId, r.groupInfo.membership, byMember = r.member, r.groupInfo) } } } is CR.DeletedMember -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) if (r.withMessages) { - removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + chatModel.chatsContext.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) } } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) if (r.withMessages) { - removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) + chatModel.secondaryChatsContext.value?.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) } } } is CR.LeftMember -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.MemberRole -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.MembersRoleUser -> if (active(r.user)) { - withChats { + withContext(Dispatchers.Main) { r.members.forEach { member -> - upsertGroupMember(rhId, r.groupInfo, member) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, member) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { r.members.forEach { member -> - upsertGroupMember(rhId, r.groupInfo, member) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, member) } } } is CR.MemberBlockedForAll -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } is CR.UserJoinedGroup -> if (active(r.user)) { - withChats { - updateGroup(rhId, r.groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } is CR.JoinedGroupMember -> if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } is CR.ConnectedToGroupMember -> { if (active(r.user)) { - withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } if (r.memberContact != null) { @@ -2789,14 +2802,14 @@ object ChatController { } is CR.GroupUpdated -> if (active(r.user)) { - withChats { - updateGroup(rhId, r.toGroup) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } is CR.RcvFileStart -> @@ -2897,26 +2910,26 @@ object ChatController { } is CR.ContactSwitch -> if (active(r.user)) { - withChats { - updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) } } is CR.GroupMemberSwitch -> if (active(r.user)) { - withChats { - updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) } } is CR.ContactRatchetSync -> if (active(r.user)) { - withChats { - updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) } } is CR.GroupMemberRatchetSync -> if (active(r.user)) { - withChats { - updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) } } is CR.RemoteHostSessionCode -> { @@ -2930,8 +2943,8 @@ object ChatController { } is CR.ContactDisabled -> { if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } } @@ -3055,8 +3068,8 @@ object ChatController { } is CR.ContactPQEnabled -> if (active(r.user)) { - withChats { - updateContact(rhId, r.contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, r.contact) } } is CR.ChatRespError -> when { @@ -3120,8 +3133,8 @@ object ChatController { suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { - withChats { - updateGroup(rh, groupInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rh, groupInfo) } } } @@ -3130,10 +3143,10 @@ object ChatController { if (activeUser(rh, user)) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem - withChats { upsertChatItem(rh, cInfo, cItem) } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } + withContext(Dispatchers.Main) { if (cItem.isReport) { - upsertChatItem(rh, cInfo, cItem) + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } } } @@ -3147,15 +3160,16 @@ object ChatController { return } val cInfo = ChatInfo.Group(r.groupInfo) - withChats { + withContext(Dispatchers.Main) { + val chatsCtx = chatModel.chatsContext r.chatItemIDs.forEach { itemId -> - decreaseGroupReportsCounter(rhId, cInfo.id) - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + chatsCtx.decreaseGroupReportsCounter(rhId, cInfo.id) + val cItem = chatsCtx.chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach if (chatModel.chatId.value != null) { // Stop voice playback only inside a chat, allow to play in a chat list AudioPlayer.stop(cItem) } - val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + val isLastChatItem = chatsCtx.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { ntfManager.cancelNotificationsForChat(cInfo.id) ntfManager.displayNotification( @@ -3170,22 +3184,25 @@ object ChatController { } else { CIDeleted.Deleted(Clock.System.now()) } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + chatsCtx.upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) } } - withReportsChatsIfOpen { - r.chatItemIDs.forEach { itemId -> - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach - if (chatModel.chatId.value != null) { - // Stop voice playback only inside a chat, allow to play in a chat list - AudioPlayer.stop(cItem) + withContext(Dispatchers.Main) { + val chatsCtx = chatModel.secondaryChatsContext.value + if (chatsCtx != null) { + r.chatItemIDs.forEach { itemId -> + val cItem = chatsCtx.chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + chatsCtx.upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { - CIDeleted.Moderated(Clock.System.now(), r.member_) - } else { - CIDeleted.Deleted(Clock.System.now()) - } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) } } } @@ -3197,8 +3214,12 @@ object ChatController { if (!activeUser(rh, user)) { notify() } else { - val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) } - withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } } + val createdChat = withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } + withContext(Dispatchers.Main) { + if (cItem.content.msgContent is MsgContent.MCReport) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) + } + } if (createdChat) { notify() } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { @@ -3243,15 +3264,15 @@ object ChatController { chatModel.users.addAll(users) chatModel.currentUser.value = user if (user == null) { - withChats { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() + chatModel.chatsContext.chats.clear() + chatModel.chatsContext.popChatCollector.clear() } - withReportsChatsIfOpen { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.chatItems?.clearAndNotify() + chatModel.secondaryChatsContext.value?.chats?.clear() + chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } val statuses = apiGetNetworkStatuses(rhId) @@ -3430,7 +3451,7 @@ sealed class CC { class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() - class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC() + class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole, val short: Boolean): CC() class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() @@ -3469,11 +3490,11 @@ sealed class CC { class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() - class APIAddContact(val userId: Long, val incognito: Boolean): CC() + class APIAddContact(val userId: Long, val short: Boolean, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() - class APIConnectPlan(val userId: Long, val connReq: String): CC() - class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() + class APIConnectPlan(val userId: Long, val connLink: String): CC() + class APIConnect(val userId: Long, val incognito: Boolean, val connLink: CreatedConnLink): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() @@ -3485,7 +3506,7 @@ sealed class CC { class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC() class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC() - class ApiCreateMyAddress(val userId: Long): CC() + class ApiCreateMyAddress(val userId: Long, val short: Boolean): CC() class ApiDeleteMyAddress(val userId: Long): CC() class ApiShowMyAddress(val userId: Long): CC() class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC() @@ -3615,7 +3636,7 @@ sealed class CC { is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" - is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()}" + is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()} short=${onOff(short)}" is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" @@ -3654,11 +3675,11 @@ sealed class CC { is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" - is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" + is APIAddContact -> "/_connect $userId short=${onOff(short)} incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" - is APIConnectPlan -> "/_connect plan $userId $connReq" - is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" + is APIConnectPlan -> "/_connect plan $userId $connLink" + is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" @@ -3670,7 +3691,7 @@ sealed class CC { is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}" is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}" - is ApiCreateMyAddress -> "/_address $userId" + is ApiCreateMyAddress -> "/_address $userId short=${onOff(short)}" is ApiDeleteMyAddress -> "/_delete_address $userId" is ApiShowMyAddress -> "/_show_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" @@ -5763,10 +5784,10 @@ sealed class CR { @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List, val chatTags: List): CR() - @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() + @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connLinkInvitation: CreatedConnLink, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() - @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() + @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @@ -5783,7 +5804,7 @@ sealed class CR { @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val user: User, val contactLink: UserContactLinkRec): CR() - @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connReqContact: String): CR() + @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connLinkContact: CreatedConnLink): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted(val user: User): CR() @Serializable @SerialName("contactConnected") class ContactConnected(val user: UserRef, val contact: Contact, val userCustomProfile: Profile? = null): CR() @Serializable @SerialName("contactConnecting") class ContactConnecting(val user: UserRef, val contact: Contact): CR() @@ -5840,8 +5861,8 @@ sealed class CR { @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: UserRef, val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() - @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() - @Serializable @SerialName("groupLink") class GroupLink(val user: UserRef, val groupInfo: GroupInfo, val connReqContact: String, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val connLinkContact: CreatedConnLink, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupLink") class GroupLink(val user: UserRef, val groupInfo: GroupInfo, val connLinkContact: CreatedConnLink, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("newMemberContact") class NewMemberContact(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("newMemberContactSentInv") class NewMemberContactSentInv(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -6129,10 +6150,10 @@ sealed class CR { is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}") - is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") + is Invitation -> withUser(user, "connLinkInvitation: ${json.encodeToString(connLinkInvitation)}\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) - is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) + is CRConnectionPlan -> withUser(user, "connLink: ${json.encodeToString(connLink)}\nconnectionPlan: ${json.encodeToString(connectionPlan)}") is SentConfirmation -> withUser(user, json.encodeToString(connection)) is SentInvitation -> withUser(user, json.encodeToString(connection)) is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) @@ -6149,7 +6170,7 @@ sealed class CR { is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is UserContactLink -> withUser(user, contactLink.responseDetails) is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails) - is UserContactLinkCreated -> withUser(user, connReqContact) + is UserContactLinkCreated -> withUser(user, json.encodeToString(connLinkContact)) is UserContactLinkDeleted -> withUser(user, noDetails()) is ContactConnected -> withUser(user, json.encodeToString(contact)) is ContactConnecting -> withUser(user, json.encodeToString(contact)) @@ -6203,8 +6224,8 @@ sealed class CR { is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) - is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") - is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact\nmemberRole: $memberRole") + is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnLinkContact: $connLinkContact\nmemberRole: $memberRole") + is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnLinkContact: $connLinkContact\nmemberRole: $memberRole") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) is NewMemberContact -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") @@ -6314,11 +6335,34 @@ sealed class ChatDeleteMode { } } +@Serializable +data class CreatedConnLink(val connFullLink: String, val connShortLink: String?) { + fun simplexChatUri(short: Boolean): String = + if (short) connShortLink ?: simplexChatLink(connFullLink) + else simplexChatLink(connFullLink) + + companion object { + val nullableStateSaver: Saver> = Saver( + save = { link -> link?.connFullLink to link?.connShortLink }, + restore = { saved -> + val connFullLink = saved.first + if (connFullLink == null) null + else CreatedConnLink(connFullLink = connFullLink, connShortLink = saved.second) + } + ) + } +} + +fun simplexChatLink(uri: String): String = + if (uri.startsWith("simplex:/")) uri.replace("simplex:/", "https://simplex.chat/") + else uri + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + @Serializable @SerialName("error") class Error(val chatError: ChatError): ConnectionPlan() } @Serializable @@ -6451,8 +6495,8 @@ enum class RatchetSyncState { } @Serializable -class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) { - val responseDetails: String get() = "connReqContact: ${connReqContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}" +class UserContactLinkRec(val connLinkContact: CreatedConnLink, val autoAccept: AutoAccept? = null) { + val responseDetails: String get() = "connLinkContact: ${connLinkContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}" } @Serializable @@ -6544,6 +6588,7 @@ sealed class ChatErrorType { is ChatStoreChanged -> "chatStoreChanged" is ConnectionPlanChatError -> "connectionPlan" is InvalidConnReq -> "invalidConnReq" + is UnsupportedConnReq -> "unsupportedConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" is ContactNotActive -> "contactNotActive" @@ -6622,6 +6667,7 @@ sealed class ChatErrorType { @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() @Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() + @Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType() @@ -6741,6 +6787,7 @@ sealed class StoreError { is ContactNotFoundByFileId -> "contactNotFoundByFileId" is NoGroupSndStatus -> "noGroupSndStatus" is LargeMsg -> "largeMsg" + is DBException -> "dBException" } @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @@ -6801,6 +6848,7 @@ sealed class StoreError { @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() @Serializable @SerialName("largeMsg") object LargeMsg: StoreError() + @Serializable @SerialName("dBException") class DBException(val message: String): StoreError() } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 5efd3747a3..5b9e63963c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -73,7 +73,7 @@ abstract class NtfManager { } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true - if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo) + if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(secondaryChatsCtx = null, rhId = null, cInfo) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index a730bd1b71..2a77d0a6dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* @@ -37,17 +36,16 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.group.ChatTTLSection -import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.database.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR +import kotlinx.coroutines.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -57,6 +55,7 @@ import java.io.File @Composable fun ChatInfoView( + chatsCtx: ChatModel.ChatsContext, chatModel: ChatModel, contact: Contact, connectionStats: ConnectionStats?, @@ -99,7 +98,7 @@ fun ChatInfoView( val previousChatTTL = chatItemTTL.value chatItemTTL.value = it - setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, connStats = connStats, contactNetworkStatus.value, @@ -126,8 +125,8 @@ fun ChatInfoView( val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - withChats { - updateContactConnectionStats(chatRh, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, cStats) } } close.invoke() @@ -140,8 +139,8 @@ fun ChatInfoView( val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - withChats { - updateContactConnectionStats(chatRh, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, cStats) } } } @@ -171,8 +170,8 @@ fun ChatInfoView( verify = { code -> chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r -> val (verified, existingCode) = r - withChats { - updateContact( + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact( chatRh, ct.copy( activeConn = ct.activeConn?.copy( @@ -200,8 +199,8 @@ suspend fun syncContactConnection(rhId: Long?, contact: Contact, connectionStats val cStats = chatModel.controller.apiSyncContactRatchet(rhId, contact.contactId, force = force) connectionStats.value = cStats if (cStats != null) { - withChats { - updateContactConnectionStats(rhId, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(rhId, contact, cStats) } } } @@ -475,14 +474,14 @@ fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDe val chatRh = chat.remoteHostId val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode) if (ct != null) { - withChats { + withContext(Dispatchers.Main) { when (chatDeleteMode) { is ChatDeleteMode.Full -> - removeChat(chatRh, chatInfo.id) + chatModel.chatsContext.removeChat(chatRh, chatInfo.id) is ChatDeleteMode.Entity -> - updateContact(chatRh, ct) + chatModel.chatsContext.updateContact(chatRh, ct) is ChatDeleteMode.Messages -> - clearChat(chatRh, ChatInfo.Direct(ct)) + chatModel.chatsContext.clearChat(chatRh, ChatInfo.Direct(ct)) } } if (chatModel.chatId.value == chatInfo.id) { @@ -1270,11 +1269,11 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c wallpaperFilesToDelete.forEach(::removeWallpaperFile) if (controller.apiSetChatUIThemes(chat.remoteHostId, chat.id, changedThemes)) { - withChats { + withContext(Dispatchers.Main) { if (chat.chatInfo is ChatInfo.Direct) { - updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes))) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes))) } else if (chat.chatInfo is ChatInfo.Group) { - updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes))) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes))) } } } @@ -1283,8 +1282,8 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { - withChats { - updateContact(chatRh, it) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(chatRh, it) } } } @@ -1334,6 +1333,7 @@ fun queueInfoText(info: Pair): String { } fun setChatTTLAlert( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, selectedChatTTL: MutableState, @@ -1353,7 +1353,7 @@ fun setChatTTLAlert( } else MR.strings.enable_automatic_deletion_question), text = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion_message else MR.strings.change_automatic_chat_deletion_message), confirmText = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion else MR.strings.delete_messages), - onConfirm = { setChatTTL(rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, + onConfirm = { setChatTTL(chatsCtx, rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, onDismiss = { selectedChatTTL.value = previousChatTTL }, onDismissRequest = { selectedChatTTL.value = previousChatTTL }, destructive = true, @@ -1361,6 +1361,7 @@ fun setChatTTLAlert( } private fun setChatTTL( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, chatTTL: MutableState, @@ -1371,33 +1372,33 @@ private fun setChatTTL( withBGApi { try { chatModel.controller.setChatTTL(rhId, chatInfo.chatType, chatInfo.apiId, chatTTL.value) - afterSetChatTTL(rhId, chatInfo, progressIndicator) + afterSetChatTTL(chatsCtx, rhId, chatInfo, progressIndicator) } catch (e: Exception) { chatTTL.value = previousChatTTL - afterSetChatTTL(rhId, chatInfo, progressIndicator) + afterSetChatTTL(chatsCtx, rhId, chatInfo, progressIndicator) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString()) } } } -private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState) { +private suspend fun afterSetChatTTL(chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState) { try { val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, null, pagination) ?: return if (chat.chatItems.isEmpty()) { // replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview - withChats { - val oldChat = getChat(chat.id) + withContext(Dispatchers.Main) { + val oldChat = chatModel.chatsContext.getChat(chat.id) if (oldChat != null) { - replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList())) + chatModel.chatsContext.replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList())) } } } if (chat.remoteHostId != chatModel.remoteHostId() || chat.id != chatModel.chatId.value) return processLoadedChat( + chatsCtx, chat, navInfo, - contentTag = null, pagination = pagination, openAroundItemId = null ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index c13eb8f139..9c36f4896b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -209,7 +209,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools SectionItemView( click = { withBGApi { - openChat(chatRh, forwardedFromItem.chatInfo) + openChat(secondaryChatsCtx = null, chatRh, forwardedFromItem.chatInfo) ModalManager.end.closeModals() } }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 385bf42397..eabe9cb60a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -2,7 +2,6 @@ package chat.simplex.common.views.chat import androidx.compose.runtime.snapshots.SnapshotStateList import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.chatModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.StateFlow @@ -12,65 +11,65 @@ import kotlin.math.min const val TRIM_KEEP_COUNT = 200 suspend fun apiLoadSingleMessage( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatType: ChatType, apiId: Long, - itemId: Long, - contentTag: MsgContentTag?, + itemId: Long ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } suspend fun apiLoadMessages( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatType: ChatType, apiId: Long, - contentTag: MsgContentTag?, pagination: ChatPagination, search: String = "", openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, pagination, search) ?: return@coroutineScope // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes /** When [openAroundItemId] is provided, chatId can be different too */ if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) || !isActive) return@coroutineScope - processLoadedChat(chat, navInfo, contentTag, pagination, openAroundItemId, visibleItemIndexesNonReversed) + processLoadedChat(chatsCtx, chat, navInfo, pagination, openAroundItemId, visibleItemIndexesNonReversed) } suspend fun processLoadedChat( + chatsCtx: ChatModel.ChatsContext, chat: Chat, navInfo: NavigationInfo, - contentTag: MsgContentTag?, pagination: ChatPagination, openAroundItemId: Long?, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) { - val chatState = chatModel.chatStateForContent(contentTag) + val chatState = chatsCtx.chatState val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState - val oldItems = chatModel.chatItemsForContent(contentTag).value + val oldItems = chatsCtx.chatItems.value val newItems = SnapshotStateList() when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() - if (contentTag == null) { + if (chatsCtx.contentTag == null) { // update main chats, not content tagged - withChats { - val oldChat = getChat(chat.id) + withContext(Dispatchers.Main) { + val oldChat = chatModel.chatsContext.getChat(chat.id) if (oldChat == null) { - addChat(chat) + chatModel.chatsContext.addChat(chat) } else { - updateChatInfo(chat.remoteHostId, chat.chatInfo) + chatModel.chatsContext.updateChatInfo(chat.remoteHostId, chat.chatInfo) // unreadChat is currently not actual in getChat query (always false) - updateChatStats(chat.remoteHostId, chat.id, chat.chatStats.copy(unreadChat = oldChat.chatStats.unreadChat)) + chatModel.chatsContext.updateChatStats(chat.remoteHostId, chat.id, chat.chatStats.copy(unreadChat = oldChat.chatStats.unreadChat)) } } } - withChats(contentTag) { - chatItemStatuses.clear() - chatItems.replaceAll(chat.chatItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItemStatuses.clear() + chatsCtx.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { @@ -93,8 +92,8 @@ suspend fun processLoadedChat( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) } @@ -112,8 +111,8 @@ suspend fun processLoadedChat( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -134,8 +133,8 @@ suspend fun processLoadedChat( newItems.addAll(itemIndex, chat.chatItems) newSplits.add(splitIndex, chat.chatItems.last().id) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal @@ -157,8 +156,8 @@ suspend fun processLoadedChat( val newSplits = removeDuplicatesAndUnusedSplits(newItems, chat, chatState.splits.value) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withChats(contentTag) { - chatItems.replaceAll(newItems) + withContext(Dispatchers.Main) { + chatsCtx.chatItems.replaceAll(newItems) chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index d318cf05fd..d98c041478 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -237,24 +237,8 @@ data class ActiveChatState ( unreadAfter.value = 0 unreadAfterNewestLoaded.value = 0 } -} -fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { - val zero = 0 .. 0 - if (listState.layoutInfo.totalItemsCount == 0) return zero - val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems - val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() - if (newest == null || oldest == null) return zero - val range = reversedItemsSize - oldest .. reversedItemsSize - newest - if (range.first < 0 || range.last < 0) return zero - - // visible items mapped to their underlying data structure which is chatModel.chatItems - return range -} - -fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItemsChangesListener { - override fun read(itemIds: Set?, newItems: List) { - val (_, unreadAfterItemId, _, unreadTotal, unreadAfter) = chatState + fun itemsRead(itemIds: Set?, newItems: List) { if (itemIds == null) { // special case when the whole chat became read unreadTotal.value = 0 @@ -287,14 +271,15 @@ fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItem unreadTotal.value = newUnreadTotal unreadAfter.value = newUnreadAfter } - override fun added(item: Pair, index: Int) { + + fun itemAdded(item: Pair) { if (item.second) { - chatState.unreadAfter.value++ - chatState.unreadTotal.value++ + unreadAfter.value++ + unreadTotal.value++ } } - override fun removed(itemIds: List>, newItems: List) { - val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter) = chatState + + fun itemsRemoved(itemIds: List>, newItems: List) { val newSplits = ArrayList() for (split in splits.value) { val index = itemIds.indexOfFirst { it.first == split } @@ -343,7 +328,19 @@ fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItem totalAfter.value -= itemIds.size } } - override fun cleared() { chatState.clear() } +} + +fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { + val zero = 0 .. 0 + if (listState.layoutInfo.totalItemsCount == 0) return zero + val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems + val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() + if (newest == null || oldest == null) return zero + val range = reversedItemsSize - oldest .. reversedItemsSize - newest + if (range.first < 0 || range.last < 0) return zero + + // visible items mapped to their underlying data structure which is chatModel.chatItems + return range } /** Helps in debugging */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 94a5fd3549..ef82b9a35b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -32,8 +32,6 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -59,8 +57,8 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts fun ChatView( + chatsCtx: ChatModel.ChatsContext, staleChatId: State, - contentTag: MsgContentTag?, scrollToItemId: MutableState = remember { mutableStateOf(null) }, onComposed: suspend (chatId: String) -> Unit ) { @@ -101,7 +99,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (contentTag == null) { + if (chatsCtx.contentTag == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -116,13 +114,12 @@ fun ChatView( // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chatsForContent(contentTag).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 + chatsCtx.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), - LocalContentTag provides contentTag ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -136,15 +133,16 @@ fun ChatView( val sameText = searchText.value == value // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it // (required on Android to have this check to prevent call to search with old text) - val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && contentTag == null + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.contentTag == null val c = chatModel.getChat(chatInfo.id) if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(c, value, contentTag) + apiFindMessages(chatsCtx, c, value) searchText.value = value } } ChatLayout( + chatsCtx = chatsCtx, remoteHostId = remoteHostId, chatInfo = activeChatInfo, unreadCount, @@ -176,7 +174,7 @@ fun ChatView( } } else { SelectedItemsButtonsToolbar( - contentTag = contentTag, + chatsCtx = chatsCtx, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -264,7 +262,7 @@ fun ChatView( // The idea is to preload information before showing a modal because large groups can take time to load all members var preloadedContactInfo: Pair? = null var preloadedCode: String? = null - var preloadedLink: Pair? = null + var preloadedLink: Pair? = null if (chatInfo is ChatInfo.Direct) { preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second @@ -288,17 +286,17 @@ fun ChatView( code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second preloadedCode = code } - ChatInfoView(chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) { + ChatInfoView(chatsCtx, chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) { showSearch.value = true } } else if (chatInfo is ChatInfo.Group) { - var link: Pair? by remember(chatInfo.id) { mutableStateOf(preloadedLink) } + var link: Pair? by remember(chatInfo.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chatInfo.id) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, { + GroupChatInfoView(chatsCtx, chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, { link = it preloadedLink = it }, close, { showSearch.value = true }) @@ -345,7 +343,7 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (contentTag == null) { + if (chatsCtx.contentTag == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> @@ -359,12 +357,12 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, null, visibleItemIndexes) + apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = reversedChatItemsStatic(contentTag).lastOrNull { it.id == itemId } + val toDeleteItem = reversedChatItemsStatic(chatsCtx).lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -389,23 +387,23 @@ fun ChatView( if (deleted != null) { deletedChatItem = deleted.deletedChatItem.chatItem toChatItem = deleted.toChatItem?.chatItem - withChats { + withContext(Dispatchers.Main) { if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) + chatModel.chatsContext.upsertChatItem(chatRh, chatInfo, toChatItem) } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) + chatModel.chatsContext.removeChatItem(chatRh, chatInfo, deletedChatItem) } val deletedItem = deleted.deletedChatItem.chatItem if (deletedItem.isActiveReport) { - decreaseGroupReportsCounter(chatRh, chatInfo.id) + chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (deletedChatItem.isReport) { if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) } } } @@ -463,8 +461,8 @@ fun ChatView( if (r != null) { val contactStats = r.first if (contactStats != null) - withChats { - updateContactConnectionStats(chatRh, contact, contactStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, contactStats) } } } @@ -475,8 +473,8 @@ fun ChatView( if (r != null) { val memStats = r.second if (memStats != null) { - withChats { - updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) } } } @@ -486,8 +484,8 @@ fun ChatView( withBGApi { val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) if (cStats != null) { - withChats { - updateContactConnectionStats(chatRh, contact, cStats) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, contact, cStats) } } } @@ -496,8 +494,8 @@ fun ChatView( withBGApi { val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { - withChats { - updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) } } } @@ -519,12 +517,12 @@ fun ChatView( reaction = reaction ) if (updatedCI != null) { - withChats { - updateChatItem(cInfo, updatedCI) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(cInfo, updatedCI) } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { if (cItem.isReport) { - updateChatItem(cInfo, updatedCI) + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) } } } @@ -544,7 +542,7 @@ fun ChatView( groupMembersJob.cancel() groupMembersJob = scope.launch(Dispatchers.Default) { var initialCiInfo = loadChatItemInfo() ?: return@launch - if (!ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + if (!ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(endButtons = { @@ -578,11 +576,8 @@ fun ChatView( openGroupLink = { groupInfo -> openGroupLink(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) }, markItemsRead = { itemsIds -> withBGApi { - withChats { - // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace - withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo.id, itemsIds) - } + withContext(Dispatchers.Main) { + chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatItemsRead( chatRh, @@ -591,18 +586,15 @@ fun ChatView( itemsIds ) } - withReportsChatsIfOpen { - markChatItemsRead(chatRh, chatInfo.id, itemsIds) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id, itemsIds) } } }, markChatRead = { withBGApi { - withChats { - // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace - withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo.id) - } + withContext(Dispatchers.Main) { + chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id) ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatRead( chatRh, @@ -610,8 +602,8 @@ fun ChatView( chatInfo.apiId ) } - withReportsChatsIfOpen { - markChatItemsRead(chatRh, chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id) } } }, @@ -631,13 +623,13 @@ fun ChatView( is ChatInfo.ContactConnection -> { val close = { chatModel.chatId.value = null } ModalView(close, showClose = appPlatform.isAndroid, content = { - ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close) + ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, false, close) }) LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - withChats { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() } } } @@ -649,8 +641,8 @@ fun ChatView( LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - withChats { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() } } } @@ -675,6 +667,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) @Composable fun ChatLayout( + chatsCtx: ChatModel.ChatsContext, remoteHostId: State, chatInfo: State, unreadCount: State, @@ -752,8 +745,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - val contentTag = LocalContentTag.current - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, contentTag == null)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.contentTag == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -767,7 +759,7 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, + chatsCtx, remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, @@ -790,7 +782,7 @@ fun ChatLayout( } } } - if (contentTag == MsgContentTag.Report) { + if (chatsCtx.contentTag == MsgContentTag.Report) { Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -801,7 +793,7 @@ fun ChatLayout( AnimatedVisibility(selectedChatItems.value != null) { if (chatInfo != null) { SelectedItemsButtonsToolbar( - contentTag = contentTag, + chatsCtx = chatsCtx, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { _ -> @@ -841,7 +833,7 @@ fun ChatLayout( } val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (contentTag == null && reportsCount > 0) { + if (chatsCtx.contentTag == null && reportsCount > 0) { ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() @@ -849,14 +841,14 @@ fun ChatLayout( } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (contentTag == MsgContentTag.Report) { + if (chatsCtx.contentTag == MsgContentTag.Report) { if (oneHandUI.value) { StatusBarBackground() } Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { Box { if (selectedChatItems.value == null) { - GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged) + GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) } @@ -867,13 +859,13 @@ fun ChatLayout( Box { if (selectedChatItems.value == null) { if (chatInfo != null) { - ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) } } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + if (chatsCtx.contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) } } @@ -885,8 +877,8 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, - contentTag: MsgContentTag?, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -908,7 +900,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && contentTag == null) { + if (appPlatform.isAndroid && chatsCtx.contentTag == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -1147,6 +1139,7 @@ private var reportsListState: LazyListState? = null @Composable fun BoxScope.ChatItemsList( + chatsCtx: ChatModel.ChatsContext, remoteHostId: Long?, chatInfo: ChatInfo, unreadCount: State, @@ -1204,106 +1197,107 @@ fun BoxScope.ChatItemsList( val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val contentTag = LocalContentTag.current // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state - val mergedItems = remember { - derivedStateOf { - MergedItems.create(chatModel.chatItemsForContent(contentTag).value.asReversed(), unreadCount, revealedItems.value, chatModel.chatStateForContent(contentTag)) + if (chatsCtx != null) { + val mergedItems = remember { + derivedStateOf { + MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) + } } - } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value.asReversed() } } - val reportsCount = reportsCount(chatInfo.id) - val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0) - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) - val numberOfBottomAppBars = numberOfBottomAppBars() - /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of - * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears - * */ - val maxHeightForList = rememberUpdatedState( - with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } - ) - val resetListState = remember { mutableStateOf(false) } - remember(chatModel.openAroundItemId.value) { - if (chatModel.openAroundItemId.value != null) { - closeSearch() - resetListState.value = !resetListState.value + val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } + val reportsCount = reportsCount(chatInfo.id) + val topPaddingToContent = topPaddingToContent( + chatView = chatsCtx.contentTag == null, + additionalTopBar = chatsCtx.contentTag == null && reportsCount > 0 + ) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) + val numberOfBottomAppBars = numberOfBottomAppBars() + + /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of + * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears + * */ + val maxHeightForList = rememberUpdatedState( + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } + ) + val resetListState = remember { mutableStateOf(false) } + remember(chatModel.openAroundItemId.value) { + if (chatModel.openAroundItemId.value != null) { + closeSearch() + resetListState.value = !resetListState.value + } } - } - val highlightedItems = remember { mutableStateOf(setOf()) } - val hoveredItemId = remember { mutableStateOf(null as Long?) } - val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { - val openAroundItemId = chatModel.openAroundItemId.value - val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } - val reportsState = reportsListState - if (openAroundItemId != null) { - highlightedItems.value += openAroundItemId - chatModel.openAroundItemId.value = null + val highlightedItems = remember { mutableStateOf(setOf()) } + val hoveredItemId = remember { mutableStateOf(null as Long?) } + val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { + val openAroundItemId = chatModel.openAroundItemId.value + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } + val reportsState = reportsListState + if (openAroundItemId != null) { + highlightedItems.value += openAroundItemId + chatModel.openAroundItemId.value = null + } + hoveredItemId.value = null + if (reportsState != null) { + reportsListState = null + reportsState + } else if (index <= 0 || !searchValueIsEmpty.value) { + LazyListState(0, 0) + } else { + LazyListState(index + 1, -maxHeightForList.value) + } + }) + SaveReportsStateOnDispose(chatsCtx, listState) + val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } + val loadingMoreItems = remember { mutableStateOf(false) } + val animatedScrollingInProgress = remember { mutableStateOf(false) } + val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } - hoveredItemId.value = null - if (reportsState != null) { - reportsListState = null - reportsState - } else if (index <= 0 || !searchValueIsEmpty.value) { - LazyListState(0, 0) - } else { - LazyListState(index + 1, -maxHeightForList.value) - } - }) - SaveReportsStateOnDispose(listState) - val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } - val loadingMoreItems = remember { mutableStateOf(false) } - val animatedScrollingInProgress = remember { mutableStateOf(false) } - val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } - LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { - if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) - ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) - } - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> - if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false - loadingMoreItems.value = true - withContext(NonCancellable) { - try { - loadMessages(chatId, pagination) { - visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) + PreloadItems(chatsCtx, chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false + loadingMoreItems.value = true + withContext(NonCancellable) { + try { + loadMessages(chatId, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) + } + } finally { + loadingMoreItems.value = false + } + } + true + } + val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) + val chatInfoUpdated = rememberUpdatedState(chatInfo) + val scope = rememberCoroutineScope() + val scrollToItem: (Long) -> Unit = remember { + // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling + if (chatsCtx.contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } + scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } + if (chatsCtx.contentTag == null) { + LaunchedEffect(Unit) { + snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() + } + scrollToItem(it) + scrollToItemId.value = null } - } finally { - loadingMoreItems.value = false } } - true - } + SmallScrollOnNewMessage(listState, reversedChatItems) + val finishedInitialComposition = remember { mutableStateOf(false) } + NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) - val chatInfoUpdated = rememberUpdatedState(chatInfo) - val scope = rememberCoroutineScope() - val scrollToItem: (Long) -> Unit = remember { - // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling - if (contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) - } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, contentTag) } - if (contentTag == null) { - LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { - if (appPlatform.isAndroid) { - ModalManager.end.closeModals() + DisposableEffectOnGone( + whenGone = { + VideoPlayerHolder.releaseAll() } - scrollToItem(it) - scrollToItemId.value = null } - } - } - SmallScrollOnNewMessage(listState, reversedChatItems) - val finishedInitialComposition = remember { mutableStateOf(false) } - NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - - DisposableEffectOnGone( - always = { - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) - }, - whenGone = { - VideoPlayerHolder.releaseAll() - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) - } - ) + ) @Composable fun ChatViewListItem( @@ -1348,7 +1342,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -1485,7 +1479,7 @@ fun BoxScope.ChatItemsList( } } else { ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Row( @@ -1500,7 +1494,7 @@ fun BoxScope.ChatItemsList( } } else { ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } Box( @@ -1515,7 +1509,7 @@ fun BoxScope.ChatItemsList( } } else { // direct message ChatItemBox { - AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } @@ -1545,111 +1539,112 @@ fun BoxScope.ChatItemsList( ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) } } - LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), - state = listState.value, - contentPadding = PaddingValues( - top = topPaddingToContent, - bottom = composeViewHeight.value - ), - reverseLayout = true, - additionalBarOffset = composeViewHeight, - additionalTopBar = rememberUpdatedState(contentTag == null && reportsCount > 0), - chatBottomBar = remember { appPrefs.chatBottomBar.state } - ) { - val mergedItemsValue = mergedItems.value - itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> - val isLastItem = index == mergedItemsValue.items.lastIndex - val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null - val listItem = merged.newest() - val item = listItem.item - val range = if (merged is MergedItem.Grouped) { - merged.rangeInReversed.value - } else { - null - } - val showAvatar = shouldShowAvatar(item, listItem.nextItem) - val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } - val itemSeparation: ItemSeparation - val prevItemSeparationLargeGap: Boolean - if (merged is MergedItem.Single || isRevealed.value) { - val prev = listItem.prevItem - itemSeparation = getItemSeparation(item, prev) - val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem - prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) - } else { - itemSeparation = getItemSeparation(item, null) - prevItemSeparationLargeGap = false - } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) - } - - if (last != null) { - // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items - DateSeparator(last.meta.itemTs) - } - if (item.isRcvNew) { - val itemIds = when (merged) { - is MergedItem.Single -> listOf(merged.item.item.id) - is MergedItem.Grouped -> merged.items.map { it.item.id } + LazyColumnWithScrollBar( + Modifier.align(Alignment.BottomCenter), + state = listState.value, + contentPadding = PaddingValues( + top = topPaddingToContent, + bottom = composeViewHeight.value + ), + reverseLayout = true, + additionalBarOffset = composeViewHeight, + additionalTopBar = rememberUpdatedState(chatsCtx.contentTag == null && reportsCount > 0), + chatBottomBar = remember { appPrefs.chatBottomBar.state } + ) { + val mergedItemsValue = mergedItems.value + itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> + val isLastItem = index == mergedItemsValue.items.lastIndex + val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null + val listItem = merged.newest() + val item = listItem.item + val range = if (merged is MergedItem.Grouped) { + merged.rangeInReversed.value + } else { + null + } + val showAvatar = shouldShowAvatar(item, listItem.nextItem) + val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } + val itemSeparation: ItemSeparation + val prevItemSeparationLargeGap: Boolean + if (merged is MergedItem.Single || isRevealed.value) { + val prev = listItem.prevItem + itemSeparation = getItemSeparation(item, prev) + val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem + prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) + } else { + itemSeparation = getItemSeparation(item, null) + prevItemSeparationLargeGap = false + } + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } + + if (last != null) { + // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items + DateSeparator(last.meta.itemTs) + } + if (item.isRcvNew) { + val itemIds = when (merged) { + is MergedItem.Single -> listOf(merged.item.item.id) + is MergedItem.Grouped -> merged.items.map { it.item.id } + } + MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) } - MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) } } - } - FloatingButtons( - reversedChatItems, - chatInfoUpdated, - topPaddingToContent, - topPaddingToContentPx, - contentTag, - loadingMoreItems, - loadingTopItems, - loadingBottomItems, - animatedScrollingInProgress, - mergedItems, - unreadCount, - maxHeight, - composeViewHeight, - searchValue, - markChatRead, - listState, - loadMessages - ) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) + FloatingButtons( + chatsCtx, + reversedChatItems, + chatInfoUpdated, + topPaddingToContent, + topPaddingToContentPx, + loadingMoreItems, + loadingTopItems, + loadingBottomItems, + animatedScrollingInProgress, + mergedItems, + unreadCount, + maxHeight, + composeViewHeight, + searchValue, + markChatRead, + listState, + loadMessages + ) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) - LaunchedEffect(Unit) { - snapshotFlow { listState.value.isScrollInProgress } - .collect { - chatViewScrollState.value = it - } - } - LaunchedEffect(Unit) { - snapshotFlow { listState.value.isScrollInProgress } - .filter { !it } - .collect { - if (animatedScrollingInProgress.value) { - animatedScrollingInProgress.value = false + LaunchedEffect(Unit) { + snapshotFlow { listState.value.isScrollInProgress } + .collect { + chatViewScrollState.value = it } - } + } + LaunchedEffect(Unit) { + snapshotFlow { listState.value.isScrollInProgress } + .filter { !it } + .collect { + if (animatedScrollingInProgress.value) { + animatedScrollingInProgress.value = false + } + } + } } } -private suspend fun loadLastItems(chatId: State, contentTag: MsgContentTag?, listState: State, loadItems: State Boolean>) { +private suspend fun loadLastItems(chatsCtx: ChatModel.ChatsContext, chatId: State, listState: State, loadItems: State Boolean>) { val lastVisible = listState.value.layoutInfo.visibleItemsInfo.lastOrNull() val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size if (!itemsCanCoverScreen) return - if (lastItemsLoaded(contentTag)) return + if (lastItemsLoaded(chatsCtx)) return delay(500) loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } -private fun lastItemsLoaded(contentTag: MsgContentTag?): Boolean { - val chatState = chatModel.chatStateForContent(contentTag) - return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id +private fun lastItemsLoaded(chatsCtx: ChatModel.ChatsContext): Boolean { + val chatState = chatsCtx.chatState + return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatsCtx.chatItems.value.lastOrNull()?.id } // TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message @@ -1712,11 +1707,11 @@ private fun NotifyChatListOnFinishingComposition( @Composable fun BoxScope.FloatingButtons( + chatsCtx: ChatModel.ChatsContext, reversedChatItems: State>, chatInfo: State, topPaddingToContent: Dp, topPaddingToContentPx: State, - contentTag: MsgContentTag?, loadingMoreItems: MutableState, loadingTopItems: MutableState, loadingBottomItems: MutableState, @@ -1740,7 +1735,7 @@ fun BoxScope.FloatingButtons( fun scrollToTopUnread() { scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { - if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { + if (chatsCtx.chatState.splits.value.isNotEmpty()) { val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) val oldSize = reversedChatItems.value.size loadMessages(chatInfo.value.id, pagination) { @@ -1802,7 +1797,7 @@ fun BoxScope.FloatingButtons( animatedScrollingInProgress, composeViewHeight, onClick = { - if (loadingBottomItems.value || !lastItemsLoaded(contentTag)) { + if (loadingBottomItems.value || !lastItemsLoaded(chatsCtx)) { requestedTopScroll.value = false requestedBottomScroll.value = true } else { @@ -1877,11 +1872,11 @@ fun BoxScope.FloatingButtons( @Composable fun PreloadItems( + chatsCtx: ChatModel.ChatsContext, chatId: String, ignoreLoadingRequests: MutableSet, loadingMoreItems: State, resetListState: State, - contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1907,20 +1902,20 @@ fun PreloadItems( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .collect { firstVisibleIndex -> - if (!preloadItemsBefore(firstVisibleIndex, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)) { - preloadItemsAfter(firstVisibleIndex, chatId, contentTag, mergedItems, remaining, loadItems) + if (!preloadItemsBefore(chatsCtx, firstVisibleIndex, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems)) { + preloadItemsAfter(chatsCtx, firstVisibleIndex, chatId, mergedItems, remaining, loadItems) } - loadLastItems(chatId, contentTag, listState, loadItems) + loadLastItems(chatsCtx, chatId, listState, loadItems) } } } } private suspend fun preloadItemsBefore( + chatsCtx: ChatModel.ChatsContext, firstVisibleIndex: Int, chatId: State, ignoreLoadingRequests: State>, - contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1929,18 +1924,18 @@ private suspend fun preloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = reversedChatItemsStatic(contentTag) + val items = reversedChatItemsStatic(chatsCtx) if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { lastIndexToLoadFrom = items.lastIndex } if (lastIndexToLoadFrom != null) { val loadFromItemId = items.getOrNull(lastIndexToLoadFrom)?.id ?: return false if (!ignoreLoadingRequests.value.contains(loadFromItemId)) { - val items = reversedChatItemsStatic(contentTag) + val items = reversedChatItemsStatic(chatsCtx) val sizeWas = items.size val oldestItemIdWas = items.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - val itemsUpdated = reversedChatItemsStatic(contentTag) + val itemsUpdated = reversedChatItemsStatic(chatsCtx) if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) return false @@ -1952,14 +1947,14 @@ private suspend fun preloadItemsBefore( } private suspend fun preloadItemsAfter( + chatsCtx: ChatModel.ChatsContext, firstVisibleIndex: Int, chatId: State, - contentTag: MsgContentTag?, mergedItems: State, remaining: Int, loadItems: State Boolean>, ) { - val items = reversedChatItemsStatic(contentTag) + val items = reversedChatItemsStatic(chatsCtx) val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) @@ -2125,11 +2120,10 @@ private fun FloatingDate( } @Composable -private fun SaveReportsStateOnDispose(listState: State) { - val contentTag = LocalContentTag.current +private fun SaveReportsStateOnDispose(chatsCtx: ChatModel.ChatsContext, listState: State) { DisposableEffect(Unit) { onDispose { - reportsListState = if (contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + reportsListState = if (chatsCtx.contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) listState.value else null } } } @@ -2240,8 +2234,8 @@ fun reportsCount(staleChatId: String?): Int { } } -private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List = - chatModel.chatItemsForContent(contentTag).value.asReversed() +private fun reversedChatItemsStatic(chatsCtx: ChatModel.ChatsContext): List = + chatsCtx.chatItems.value.asReversed() private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value @@ -2317,20 +2311,20 @@ private fun scrollToItem( } private fun findQuotedItemFromItem( + chatsCtx: ChatModel.ChatsContext, rhId: State, chatInfo: State, scope: CoroutineScope, - scrollToItem: (Long) -> Unit, - contentTag: MsgContentTag? + scrollToItem: (Long) -> Unit ): (Long) -> Unit = { itemId: Long -> scope.launch(Dispatchers.Default) { - val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag) + val item = apiLoadSingleMessage(chatsCtx, rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId) if (item != null) { - withChats { - updateChatItem(chatInfo.value, item) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatItem(chatInfo.value, item) } - withReportsChatsIfOpen { - updateChatItem(chatInfo.value, item) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateChatItem(chatInfo.value, item) } if (item.quotedItem?.itemId != null) { scrollToItem(item.quotedItem.itemId) @@ -2510,28 +2504,28 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List, chatInfo: ChatInfo) { chatModel.chatId.value = null chatModel.sharedContent.value = SharedContent.Forward( - chatModel.chatItemsForContent(null).value.filter { chatItemsIds.contains(it.id) }, + chatModel.chatsContext.chatItems.value.filter { chatItemsIds.contains(it.id) }, chatInfo ) } @@ -2926,6 +2920,7 @@ fun PreviewChatLayout() { val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( + chatsCtx = ChatModel.ChatsContext(contentTag = null), remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, @@ -3003,6 +2998,7 @@ fun PreviewGroupChatLayout() { val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( + chatsCtx = ChatModel.ChatsContext(contentTag = null), remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index b48b32030f..de9fc26905 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.util.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* @@ -473,8 +472,8 @@ fun ComposeView( ) if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> - withChats { - addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + withContext(Dispatchers.Main) { + chatModel.chatsContext.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -505,9 +504,9 @@ fun ComposeView( ttl = ttl ) - withChats { + withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - addChatItem(rhId, chat.chatInfo, chatItem) + chatModel.chatsContext.addChatItem(rhId, chat.chatInfo, chatItem) } } @@ -567,9 +566,9 @@ fun ComposeView( suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) if (cItems != null) { - withChats { + withContext(Dispatchers.Main) { cItems.forEach { chatItem -> - addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + chatModel.chatsContext.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) } } } @@ -581,8 +580,8 @@ fun ComposeView( val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { - withChats { - updateContact(chat.remoteHostId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(chat.remoteHostId, contact) } } } @@ -599,8 +598,10 @@ fun ComposeView( updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) - if (updatedItem != null) withChats { - upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + if (updatedItem != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + } } return updatedItem?.chatItem } @@ -890,7 +891,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable } + val lastEditable = chatModel.chatsContext.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index b1e9bf750e..7c04c30f67 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -12,16 +12,16 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.res.MR +import kotlinx.coroutines.* @Composable fun ContactPreferencesView( @@ -41,8 +41,8 @@ fun ContactPreferencesView( val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs) if (toContact != null) { - withChats { - updateContact(rhId, toContact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, toContact) currentFeaturesAllowed = featuresAllowed } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 50e6f73bca..b9538bc691 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -59,8 +59,8 @@ private fun SelectAllButton(onClick: () -> Unit) { @Composable fun SelectedItemsButtonsToolbar( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, - contentTag: MsgContentTag?, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible archiveItems: () -> Unit, @@ -121,7 +121,7 @@ fun SelectedItemsButtonsToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - val chatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value } } + val chatItems = remember { derivedStateOf { chatsCtx.chatItems.value } } LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index abfb3895d9..10694d13bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -25,8 +25,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* @@ -35,6 +33,7 @@ import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.* @Composable fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { @@ -62,11 +61,11 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea for (contactId in selectedContacts) { val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { - withChats { - upsertGroupMember(rhId, groupInfo, member) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, member) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, member) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, member) } } else { break diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index f38cd972f3..22956738e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -32,8 +32,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -53,14 +51,15 @@ val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable fun ModalData.GroupChatInfoView( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatId: String, - groupLink: String?, + groupLink: CreatedConnLink?, groupLinkMemberRole: GroupMemberRole?, selectedItems: MutableState?>, appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, - onGroupLinkUpdated: (Pair?) -> Unit, + onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit ) { @@ -94,7 +93,7 @@ fun ModalData.GroupChatInfoView( val previousChatTTL = chatItemTTL.value chatItemTTL.value = it - setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, activeSortedMembers = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } @@ -182,8 +181,8 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl withBGApi { val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) if (r) { - withChats { - removeChat(chat.remoteHostId, chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(chat.remoteHostId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -330,7 +329,7 @@ fun ModalData.GroupChatInfoLayout( activeSortedMembers: List, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, - groupLink: String?, + groupLink: CreatedConnLink?, selectedItems: MutableState?>, appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, @@ -957,8 +956,8 @@ private fun SearchRowView( private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { - withChats { - updateGroup(chatRh, it) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(chatRh, it) } } } @@ -967,14 +966,14 @@ fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSu withBGApi { val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) if (updatedMembers != null) { - withChats { + withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, groupInfo, updatedMember) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, updatedMember) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, groupInfo, updatedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, updatedMember) } } onSuccess() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 987a80e7c0..6e1b9a731d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import SectionViewWithButton import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -15,11 +16,11 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.shareText +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR @Composable @@ -27,13 +28,13 @@ fun GroupLinkView( chatModel: ChatModel, rhId: Long?, groupInfo: GroupInfo, - connReqContact: String?, + connLinkContact: CreatedConnLink?, memberRole: GroupMemberRole?, - onGroupLinkUpdated: ((Pair?) -> Unit)?, + onGroupLinkUpdated: ((Pair?) -> Unit)?, creatingGroup: Boolean = false, close: (() -> Unit)? = null ) { - var groupLink by rememberSaveable { mutableStateOf(connReqContact) } + var groupLink by rememberSaveable(stateSaver = CreatedConnLink.nullableStateSaver) { mutableStateOf(connLinkContact) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } fun createLink() { @@ -99,7 +100,7 @@ fun GroupLinkView( @Composable fun GroupLinkLayout( - groupLink: String?, + groupLink: CreatedConnLink?, groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, @@ -150,7 +151,15 @@ fun GroupLinkLayout( } initialLaunch = false } - SimpleXLinkQRCode(groupLink) + val showShortLink = remember { mutableStateOf(true) } + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + if (groupLink.connShortLink == null) { + SimpleXCreatedLinkQRCode(groupLink, short = false) + } else { + SectionViewWithButton(titleButton = { ToggleShortLinkButton(showShortLink) }) { + SimpleXCreatedLinkQRCode(groupLink, short = showShortLink.value) + } + } Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -160,7 +169,7 @@ fun GroupLinkLayout( SimpleButton( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), - click = { clipboard.shareText(simplexChatLink(groupLink)) } + click = { clipboard.shareText(groupLink.simplexChatUri(short = showShortLink.value)) } ) if (creatingGroup && close != null) { ContinueButton(close) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 38163f9b6e..285c96165c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -27,8 +27,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -40,6 +38,7 @@ import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock +import kotlinx.coroutines.* @Composable fun GroupMemberInfoView( @@ -63,11 +62,11 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -87,7 +86,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withBGApi { - apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) + apiLoadMessages(chatModel.chatsContext, rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) if (chatModel.getContactChat(it) != null) { closeAll() } @@ -100,8 +99,8 @@ fun GroupMemberInfoView( val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) if (memberContact != null) { val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) - withChats { - addChat(memberChat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.addChat(memberChat) } openLoadedChat(memberChat) closeAll() @@ -149,11 +148,11 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -166,11 +165,11 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -186,11 +185,11 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true) if (r != null) { connStats.value = r.second - withChats { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } - withReportsChatsIfOpen { - updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } close.invoke() } @@ -212,11 +211,11 @@ fun GroupMemberInfoView( connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null ) ) - withChats { - upsertGroupMember(rhId, groupInfo, copy) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, copy) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, copy) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, copy) } r } @@ -247,14 +246,14 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withBGApi { val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) if (removedMembers != null) { - withChats { + withContext(Dispatchers.Main) { removedMembers.forEach { removedMember -> - upsertGroupMember(rhId, groupInfo, removedMember) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, removedMember) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { removedMembers.forEach { removedMember -> - upsertGroupMember(rhId, groupInfo, removedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, removedMember) } } } @@ -697,14 +696,14 @@ fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInf withBGApi { kotlin.runCatching { val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, memberIds, newRole) - withChats { + withContext(Dispatchers.Main) { members.forEach { member -> - upsertGroupMember(rhId, groupInfo, member) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, member) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { members.forEach { member -> - upsertGroupMember(rhId, groupInfo, member) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, member) } } onSuccess() @@ -798,11 +797,11 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem withBGApi { val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings) if (success) { - withChats { - upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } - withReportsChatsIfOpen { - upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } } } @@ -857,14 +856,14 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onS fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, memberIds: List, blocked: Boolean, onSuccess: () -> Unit = {}) { withBGApi { val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, memberIds, blocked) - withChats { + withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, gInfo, updatedMember) + chatModel.chatsContext.upsertGroupMember(rhId, gInfo, updatedMember) } } - withReportsChatsIfOpen { + withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - upsertGroupMember(rhId, gInfo, updatedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, gInfo, updatedMember) } } onSuccess() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index e1b0f30423..12c5b65769 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -15,9 +15,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.res.MR +import kotlinx.coroutines.* private val featureRoles: List> = listOf( null to generalGetString(MR.strings.feature_roles_all_members), @@ -42,12 +43,12 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) if (g != null) { - withChats { - updateGroup(rhId, g) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) currentPreferences = preferences } - withChats { - updateGroup(rhId, g) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) } } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 3163c109e6..fb24c028b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -17,8 +17,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* @@ -27,8 +25,7 @@ import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.net.URI @Composable @@ -40,8 +37,8 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl withBGApi { val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) if (gInfo != null) { - withChats { - updateGroup(rhId, gInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, gInfo) } close.invoke() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index 058ee59a3b..1eeeb99c93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -14,16 +14,14 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* -val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } - @Composable -private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { - ChatView(staleChatId, contentTag = MsgContentTag.Report, scrollToItemId, onComposed = {}) +private fun GroupReportsView(reportsChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState) { + ChatView(reportsChatsCtx, staleChatId, scrollToItemId, onComposed = {}) } @Composable fun GroupReportsAppBar( - contentTag: MsgContentTag?, + chatsCtx: ChatModel.ChatsContext, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -51,11 +49,11 @@ fun GroupReportsAppBar( } } ) - ItemsReload(contentTag) + ItemsReload(chatsCtx) } @Composable -private fun ItemsReload(contentTag: MsgContentTag?) { +private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -65,18 +63,19 @@ private fun ItemsReload(contentTag: MsgContentTag?) { .filterNotNull() .filter { it.chatInfo is ChatInfo.Group } .collect { chat -> - reloadItems(chat, contentTag) + reloadItems(chatsCtx, chat) } } } suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { - openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report) - ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close -> + val reportsChatsCtx = ChatModel.ChatsContext(contentTag = MsgContentTag.Report) + openChat(secondaryChatsCtx = reportsChatsCtx, chatModel.remoteHostId(), chatInfo) + ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> ModalView({}, showAppBar = false) { val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { - GroupReportsView(staleChatId, scrollToItemId) + GroupReportsView(reportsChatsCtx, staleChatId, scrollToItemId) } else { LaunchedEffect(Unit) { close() @@ -86,6 +85,6 @@ suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: Mu } } -private suspend fun reloadItems(chat: Chat, contentTag: MsgContentTag?) { - apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +private suspend fun reloadItems(chatsCtx: ChatModel.ChatsContext, chat: Chat) { + apiLoadMessages(chatsCtx, chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 703d74f225..1e99c7f527 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -26,13 +26,10 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.GroupInfo -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.chatJsonLength +import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.delay +import kotlinx.coroutines.* private const val maxByteCount = 1200 @@ -51,8 +48,8 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) if (res != null) { gInfo = res - withChats { - updateGroup(rhId, res) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, res) } welcomeText.value = welcome ?: "" } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 7711ee73af..c743d78d1c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -13,10 +13,10 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.platform.onRightClick -import chat.simplex.common.views.chat.group.LocalContentTag @Composable fun CIChatFeatureView( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, chatItem: ChatItem, feature: Feature, @@ -25,7 +25,7 @@ fun CIChatFeatureView( revealed: State, showMenu: MutableState, ) { - val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList() + val merged = if (!revealed.value) mergedFeatures(chatsCtx, chatItem, chatInfo) else emptyList() Box( Modifier .combinedClickable( @@ -72,11 +72,10 @@ private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): Feat ) @Composable -private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List? { - val m = ChatModel +private fun mergedFeatures(chatsCtx: ChatModel.ChatsContext, chatItem: ChatItem, chatInfo: ChatInfo): List? { val fs: ArrayList = arrayListOf() val icons: MutableSet = mutableSetOf() - val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed() + val reversedChatItems = chatsCtx.chatItems.value.asReversed() var i = getChatItemIndexOrNull(chatItem, reversedChatItems) if (i != null) { while (i < reversedChatItems.size) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 4eb7f56837..5bbbc33741 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -10,7 +10,6 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -30,7 +29,6 @@ import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -64,6 +62,7 @@ data class ChatItemReactionMenuItem ( @Composable fun ChatItemView( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem, @@ -278,7 +277,7 @@ fun ChatItemView( if (searchIsNotBlank.value) { GoToItemInnerButton(alignStart, MR.images.ic_search, 17.dp, parentActivated) { withBGApi { - openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id) + openChat(secondaryChatsCtx = null, rhId, cInfo.chatType, cInfo.apiId, cItem.id) closeReportsIfNeeded() } } @@ -286,7 +285,7 @@ fun ChatItemView( GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated) { val (chatType, apiId, msgId) = chatTypeApiIdMsgId withBGApi { - openChat(rhId, chatType, apiId, null, msgId) + openChat(secondaryChatsCtx = null, rhId, chatType, apiId, msgId) closeReportsIfNeeded() } } @@ -365,7 +364,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -383,7 +382,7 @@ fun ChatItemView( if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() SelectItemAction(showMenu, selectChatItem) } @@ -473,7 +472,7 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first @@ -499,7 +498,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -509,7 +508,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -523,7 +522,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -532,7 +531,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -549,7 +548,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -561,7 +560,7 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -583,7 +582,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -632,13 +631,13 @@ fun ChatItemView( } @Composable fun EventItemView() { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + val reversedChatItems = chatsCtx.chatItems.value.asReversed() CIEventView(eventItemViewText(reversedChatItems)) } @Composable fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { if (revealed.value) { HideItemAction(revealed, showMenu, reveal) @@ -648,7 +647,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -729,11 +728,11 @@ fun ChatItemView( MsgContentItemDropdownMenu() } is CIContent.RcvChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatPreference -> { @@ -742,23 +741,23 @@ fun ChatItemView( DeleteItemMenu() } is CIContent.SndChatPreference -> { - CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndModerated -> DeletedItem() @@ -830,6 +829,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( + chatsCtx: ChatModel.ChatsContext, cItem: ChatItem, revealed: State, showMenu: MutableState, @@ -838,14 +838,13 @@ fun DeleteItemAction( deleteMessages: (List) -> Unit, buttonText: String = stringResource(MR.strings.delete_verb), ) { - val contentTag = LocalContentTag.current ItemAction( buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false if (!revealed.value) { - val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed() + val reversedChatItems = chatsCtx.chatItems.value.asReversed() val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems) val ciCategory = cItem.mergeCategory if (currIndex != null && ciCategory != null) { @@ -1314,7 +1313,7 @@ fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Bo } private fun closeReportsIfNeeded() { - if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) { + if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() } } @@ -1423,6 +1422,7 @@ fun PreviewChatItemView( chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello") ) { ChatItemView( + chatsCtx = ChatModel.ChatsContext(contentTag = null), rhId = null, ChatInfo.Direct.sampleData, chatItem, @@ -1472,6 +1472,7 @@ fun PreviewChatItemView( fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( + chatsCtx = ChatModel.ChatsContext(contentTag = null), rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index f731db2df9..84bc14fee3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -12,17 +12,15 @@ import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.chatModel import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { +fun MarkedDeletedItemView(chatsCtx: ChatModel.ChatsContext, ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( @@ -35,7 +33,7 @@ fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: In verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(ci, chatInfo, revealed) + MergedMarkedDeletedText(chatsCtx, ci, chatInfo, revealed) } CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -43,8 +41,8 @@ fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: In } @Composable -private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() +private fun MergedMarkedDeletedText(chatsCtx: ChatModel.ChatsContext, chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { + val reversedChatItems = chatsCtx.chatItems.value.asReversed() var i = getChatItemIndexOrNull(chatItem, reversedChatItems) val ciCategory = chatItem.mergeCategory val text = if (!revealed.value && ciCategory != null && i != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 8b4b2bb1d2..958b794bd7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -20,8 +20,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -191,7 +189,7 @@ fun ErrorChatListItem() { suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) - else -> openChat(rhId, ChatInfo.Direct(contact)) + else -> openDirectChat(rhId, contact.contactId) } } @@ -199,30 +197,33 @@ suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId) - else -> openChat(rhId, ChatInfo.Group(groupInfo)) + else -> openGroupChat(rhId, groupInfo.groupId) } } -suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(rhId, ChatInfo.Local(noteFolder)) +suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(secondaryChatsCtx = null, rhId, ChatInfo.Local(noteFolder)) -suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId) +suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(secondaryChatsCtx = null, rhId, ChatType.Direct, contactId) -suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag) +suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(secondaryChatsCtx = null, rhId, ChatType.Group, groupId) -suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) +suspend fun openChat(secondaryChatsCtx: ChatModel.ChatsContext?, rhId: Long?, chatInfo: ChatInfo) = openChat(secondaryChatsCtx, rhId, chatInfo.chatType, chatInfo.apiId) suspend fun openChat( + secondaryChatsCtx: ChatModel.ChatsContext?, rhId: Long?, chatType: ChatType, apiId: Long, - contentTag: MsgContentTag? = null, openAroundItemId: Long? = null -) = +) { + if (secondaryChatsCtx != null) { + chatModel.secondaryChatsContext.value = secondaryChatsCtx + } apiLoadMessages( + chatsCtx = secondaryChatsCtx ?: chatModel.chatsContext, rhId, chatType, apiId, - contentTag, if (openAroundItemId != null) { ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT) } else { @@ -231,21 +232,22 @@ suspend fun openChat( "", openAroundItemId ) +} -suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { - withChats(contentTag) { - chatItemStatuses.clear() - chatItems.replaceAll(chat.chatItems) +suspend fun openLoadedChat(chat: Chat) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItemStatuses.clear() + chatModel.chatsContext.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id - chatModel.chatStateForContent(contentTag).clear() + chatModel.chatsContext.chatState.clear() } } -suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) { - withChats(contentTag) { - chatItems.clearAndNotify() +suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, search: String) { + withContext(Dispatchers.Main) { + chatsCtx.chatItems.clearAndNotify() } - apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) + apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { @@ -547,7 +549,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) + ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false }, @@ -604,11 +606,11 @@ fun markChatRead(c: Chat) { var chat = c withApi { if (chat.chatStats.unreadCount > 0) { - withChats { - markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } - withReportsChatsIfOpen { - markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } chatModel.controller.apiChatRead( chat.remoteHostId, @@ -625,9 +627,9 @@ fun markChatRead(c: Chat) { false ) if (success) { - withChats { - replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) - markChatTagRead(chat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + chatModel.chatsContext.markChatTagRead(chat) } } } @@ -647,9 +649,9 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { true ) if (success) { - withChats { - replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - updateChatTagReadNoContentTag(chat, wasUnread) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + chatModel.chatsContext.updateChatTagReadNoContentTag(chat, wasUnread) } } } @@ -690,8 +692,8 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) - withChats { - replaceChat(rhId, contactRequest.id, chat) + withContext(Dispatchers.Main) { + chatModel.chatsContext.replaceChat(rhId, contactRequest.id, chat) } chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) close?.invoke(chat) @@ -702,8 +704,8 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { withBGApi { chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId) - withChats { - removeChat(rhId, contactRequest.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, contactRequest.id) } } } @@ -720,8 +722,8 @@ fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnecti withBGApi { AlertManager.shared.hideAlert() if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) { - withChats { - removeChat(rhId, connection.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, connection.id) } onSuccess() } @@ -741,8 +743,8 @@ fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatMo withBGApi { val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId) if (r) { - withChats { - removeChat(rhId, chatInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, chatInfo.id) } if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null @@ -805,8 +807,8 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean { val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { - withChats { - updateContact(rhId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) } AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), @@ -848,8 +850,8 @@ fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { withBGApi { val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId) if (r) { - withChats { - removeChat(rhId, groupInfo.id) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, groupInfo.id) } if (chatModel.chatId.value == groupInfo.id) { chatModel.chatId.value = null @@ -903,16 +905,16 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch val wasUnread = chat?.unreadTag ?: false val wasFavorite = chatInfo.chatSettings?.favorite ?: false chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite) - withChats { - updateChatInfo(remoteHostId, newChatInfo) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatInfo(remoteHostId, newChatInfo) } if (chatSettings.enableNtfs == MsgFilter.None) { ntfManager.cancelNotificationsForChat(chatInfo.id) } val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { - withChats { - updateChatTagReadNoContentTag(updatedChat, wasUnread) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatTagReadNoContentTag(updatedChat, wasUnread) } } val current = currentState?.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3538d41f01..87c02f038c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -38,8 +37,6 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton -import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -139,7 +136,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow Unit, reorderMode: Boolean) { @@ -417,15 +416,15 @@ private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) { when (val cInfo = chat.chatInfo) { is ChatInfo.Direct -> { val contact = cInfo.contact.copy(chatTags = result.second) - withChats { - updateContact(rhId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) } } is ChatInfo.Group -> { val group = cInfo.groupInfo.copy(chatTags = result.second) - withChats { - updateGroup(rhId, group) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, group) } } @@ -453,14 +452,14 @@ private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) when (val cInfo = c.chatInfo) { is ChatInfo.Direct -> { val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId }) - withChats { - updateContact(rhId, contact) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) } } is ChatInfo.Group -> { val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId }) - withChats { - updateGroup(rhId, group) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, group) } } else -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index da70aef621..4e65a3649e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction @@ -56,13 +55,13 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State, showDel when (contactType) { ContactType.RECENT -> { withApi { - openChat(rhId, chat.chatInfo) + openChat(secondaryChatsCtx = null, rhId, chat.chatInfo) ModalManager.start.closeModals() } } ContactType.CHAT_DELETED -> { withApi { - openChat(rhId, chat.chatInfo) + openChat(secondaryChatsCtx = null, rhId, chat.chatInfo) ModalManager.start.closeModals() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 4a3e1cda54..4a911fa6f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -21,8 +21,6 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -36,6 +34,7 @@ import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.* import kotlin.collections.ArrayList +import kotlinx.coroutines.* @Composable fun DatabaseView() { @@ -538,15 +537,15 @@ fun deleteChatDatabaseFilesAndState() { // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null withLongRunningApi { - withChats { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + chatModel.chatsContext.chatItems.clearAndNotify() + chatModel.chatsContext.chats.clear() + chatModel.chatsContext.popChatCollector.clear() } - withReportsChatsIfOpen { - chatItems.clearAndNotify() - chats.clear() - popChatCollector.clear() + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.chatItems?.clearAndNotify() + chatModel.secondaryChatsContext.value?.chats?.clear() + chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } chatModel.users.clear() @@ -785,10 +784,10 @@ private fun afterSetCiTTL( appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { - withChats { + withContext(Dispatchers.Main) { // this is using current remote host on purpose - if it changes during update, it will load correct chats val chats = m.controller.apiGetChats(m.remoteHostId()) - updateChats(chats) + chatModel.chatsContext.updateChats(chats) } } catch (e: Exception) { Log.e(TAG, "apiGetChats error: ${e.message}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 564e96945c..af207d1381 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -85,7 +85,7 @@ class ModalData(val keyboardCoversBar: Boolean = true) { } enum class ModalViewId { - GROUP_REPORTS + SECONDARY_CHAT } class ModalManager(private val placement: ModalPlacement? = null) { @@ -161,13 +161,20 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { - if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex) - else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } + val lastModal = modalViews.lastOrNull() + if (lastModal != null) { + if (lastModal.id == ModalViewId.SECONDARY_CHAT) chatModel.secondaryChatsContext.value = null + if (!lastModal.animated) + modalViews.removeAt(modalViews.lastIndex) + else + runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } + } } _modalCount.value = modalViews.size - toRemove.size } fun closeModals() { + chatModel.secondaryChatsContext.value = null modalViews.clear() toRemove.clear() _modalCount.value = 0 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 37bf5b10b1..0d188bb73c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -53,6 +53,24 @@ fun SectionView( } } +@Composable +fun SectionViewWithButton(title: String? = null, titleButton: (@Composable () -> Unit)?, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) { + Column { + if (title != null || titleButton != null) { + Row(modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = headerBottomPadding).fillMaxWidth()) { + if (title != null) { + Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp) + } + if (titleButton != null) { + Spacer(modifier = Modifier.weight(1f)) + titleButton() + } + } + } + Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + } +} + @Composable fun SectionViewSelectable( title: String?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 2380c64a4c..3d913cf957 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -18,7 +18,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers @@ -30,6 +29,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.net.URI @Composable @@ -42,10 +42,10 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c withBGApi { val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) if (groupInfo != null) { - withChats { - updateGroup(rhId = rhId, groupInfo) - chatItems.clearAndNotify() - chatItemStatuses.clear() + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId = rhId, groupInfo) + chatModel.chatsContext.chatItems.clearAndNotify() + chatModel.chatsContext.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } setGroupMembers(rhId, groupInfo, chatModel) @@ -57,7 +57,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, connLinkContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 1b5b475b35..330c80b7a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -7,13 +7,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* -import java.net.URI enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -21,7 +19,7 @@ enum class ConnectionLinkType { suspend fun planAndConnect( rhId: Long?, - uri: String, + shortOrFullLink: String, incognito: Boolean?, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, @@ -29,18 +27,19 @@ suspend fun planAndConnect( filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { val completable = CompletableDeferred() - val close: (() -> Unit)? = { + val close: (() -> Unit) = { close?.invoke() // if close was called, it means the connection was created completable.complete(true) } - val cleanup: (() -> Unit)? = { + val cleanup: (() -> Unit) = { cleanup?.invoke() completable.complete(!completable.isActive) } - val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri) - if (connectionPlan != null) { - val link = strHasSingleSimplexLink(uri.trim()) + val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink) + if (result != null) { + val (connectionLink, connectionPlan) = result + val link = strHasSingleSimplexLink(shortOrFullLink.trim()) val linkText = if (link?.format is Format.SimplexLink) "

${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}" else @@ -50,10 +49,10 @@ suspend fun planAndConnect( InvitationLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_via_invitation_link), text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, @@ -68,7 +67,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, destructive = true, @@ -76,7 +75,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link) + linkText, connectDestructive = true, @@ -97,7 +96,7 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } else { AlertManager.privacySensitive.showAlertMsg( @@ -105,7 +104,7 @@ suspend fun planAndConnect( generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } is InvitationLinkPlan.Known -> { @@ -120,7 +119,7 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } } @@ -128,10 +127,10 @@ suspend fun planAndConnect( ContactAddressPlan.Ok -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_via_contact_link), text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, connectDestructive = false, @@ -146,7 +145,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, destructive = true, onDismiss = cleanup, onDismissRequest = cleanup, @@ -154,7 +153,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText, connectDestructive = true, @@ -169,7 +168,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, destructive = true, @@ -177,7 +176,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText, connectDestructive = true, @@ -197,7 +196,7 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } is ContactAddressPlan.Known -> { @@ -212,19 +211,19 @@ suspend fun planAndConnect( String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + linkText, hostDevice = hostDevice(rhId), ) - cleanup?.invoke() + cleanup() } } is ContactAddressPlan.ContactViaAddress -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact if (incognito != null) { - close?.invoke() + close() connectContactViaAddress(chatModel, rhId, contact.contactId, incognito) } else { askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false) } - cleanup?.invoke() + cleanup() } } is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { @@ -235,14 +234,14 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group) + linkText, confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group) + linkText, connectDestructive = false, @@ -256,7 +255,7 @@ suspend fun planAndConnect( if (filterKnownGroup != null) { filterKnownGroup(groupInfo) } else { - ownGroupLinkConfirmConnect(chatModel, rhId, uri, linkText, incognito, connectionPlan, groupInfo, close, cleanup) + ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, incognito, connectionPlan, groupInfo, close, cleanup) } } GroupLinkPlan.ConnectingConfirmReconnect -> { @@ -266,7 +265,7 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withBGApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onConfirm = { withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }, onDismiss = cleanup, onDismissRequest = cleanup, destructive = true, @@ -274,7 +273,7 @@ suspend fun planAndConnect( ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan, close, + chatModel, rhId, connectionLink, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, connectDestructive = true, @@ -304,7 +303,7 @@ suspend fun planAndConnect( hostDevice = hostDevice(rhId), ) } - cleanup?.invoke() + cleanup() } is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") @@ -326,22 +325,23 @@ suspend fun planAndConnect( hostDevice = hostDevice(rhId), ) } - cleanup?.invoke() + cleanup() } } } - } - } else { - Log.d(TAG, "planAndConnect, plan error") - if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close, cleanup) - } else { - askCurrentOrIncognitoProfileAlert( - chatModel, rhId, uri, connectionPlan = null, close, - title = generalGetString(MR.strings.connect_plan_connect_via_link), - connectDestructive = false, - cleanup = cleanup, - ) + is ConnectionPlan.Error -> { + Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}") + if (incognito != null) { + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan = null, close, cleanup) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, rhId, connectionLink, connectionPlan = null, close, + title = generalGetString(MR.strings.connect_plan_connect_via_link), + connectDestructive = false, + cleanup = cleanup, + ) + } + } } } return completable @@ -350,17 +350,17 @@ suspend fun planAndConnect( suspend fun connectViaUri( chatModel: ChatModel, rhId: Long?, - uri: String, + connLink: CreatedConnLink, incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, cleanup: (() -> Unit)?, ): Boolean { - val pcc = chatModel.controller.apiConnect(rhId, incognito, uri) - val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION + val pcc = chatModel.controller.apiConnect(rhId, incognito, connLink) + val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) ?: ConnectionLinkType.INVITATION else ConnectionLinkType.INVITATION if (pcc != null) { - withChats { - updateContactConnection(rhId, pcc) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, pcc) } close?.invoke() AlertManager.privacySensitive.showAlertMsg( @@ -378,18 +378,19 @@ suspend fun connectViaUri( return pcc != null } -fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { +fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType? { return when(connectionPlan) { is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION is ConnectionPlan.ContactAddress -> ConnectionLinkType.CONTACT is ConnectionPlan.GroupLink -> ConnectionLinkType.GROUP + is ConnectionPlan.Error -> null } } fun askCurrentOrIncognitoProfileAlert( chatModel: ChatModel, rhId: Long?, - uri: String, + connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, title: String, @@ -406,7 +407,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -414,7 +415,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -445,7 +446,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co fun ownGroupLinkConfirmConnect( chatModel: ChatModel, rhId: Long?, - uri: String, + connectionLink: CreatedConnLink, linkText: String, incognito: Boolean?, connectionPlan: ConnectionPlan?, @@ -471,7 +472,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito, connectionPlan, close, cleanup) } }) { Text( @@ -484,7 +485,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -493,7 +494,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withBGApi { - connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close, cleanup) + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 1623f8510d..0f299b5187 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -4,8 +4,8 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionTextFooter import SectionView +import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -22,24 +22,24 @@ import chat.simplex.common.views.chat.LocalAliasEditor import chat.simplex.common.views.chatlist.deleteContactConnectionAlert import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import kotlinx.coroutines.* @Composable fun ContactConnectionInfoView( chatModel: ChatModel, rhId: Long?, - connReqInvitation: String?, + connLinkInvitation: CreatedConnLink?, contactConnection: PendingContactConnection, focusAlias: Boolean, close: () -> Unit ) { - LaunchedEffect(connReqInvitation) { - if (connReqInvitation != null) { - chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false, conn = contactConnection) + LaunchedEffect(connLinkInvitation) { + if (connLinkInvitation != null) { + chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connLinkInvitation, false, conn = contactConnection) } } /** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation]. @@ -54,16 +54,16 @@ fun ContactConnectionInfoView( } } } - val clipboard = LocalClipboardManager.current + val showShortLink = remember { mutableStateOf(true) } ContactConnectionInfoLayout( chatModel = chatModel, - connReq = connReqInvitation, + connLink = connLinkInvitation, + showShortLink = showShortLink, contactConnection = contactConnection, focusAlias = focusAlias, rhId = rhId, deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, - share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.end.showModalCloseable { close -> AddContactLearnMore(close) @@ -75,13 +75,13 @@ fun ContactConnectionInfoView( @Composable private fun ContactConnectionInfoLayout( chatModel: ChatModel, - connReq: String?, + connLink: CreatedConnLink?, + showShortLink: MutableState, contactConnection: PendingContactConnection, focusAlias: Boolean, rhId: Long?, deleteConnection: () -> Unit, onLocalAliasChanged: (String) -> Unit, - share: () -> Unit, learnMore: () -> Unit, ) { @Composable fun incognitoEnabled() { @@ -127,13 +127,19 @@ private fun ContactConnectionInfoLayout( LocalAliasEditor(contactConnection.id, contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) } - SectionView { - if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - SimpleXLinkQRCode(connReq) + if (connLink != null && connLink.connFullLink.isNotEmpty() && contactConnection.initiated) { + Spacer(Modifier.height(DEFAULT_PADDING)) + SectionViewWithButton( + stringResource(MR.strings.one_time_link).uppercase(), + titleButton = if (connLink.connShortLink == null) null else {{ ToggleShortLinkButton(showShortLink) }} + ) { + SimpleXCreatedLinkQRCode(connLink, short = showShortLink.value) incognitoEnabled() - ShareLinkButton(connReq) + ShareLinkButton(connLink.simplexChatUri(short = showShortLink.value)) OneTimeLinkLearnMoreButton(learnMore) - } else { + } + } else { + SectionView { incognitoEnabled() OneTimeLinkLearnMoreButton(learnMore) } @@ -149,14 +155,14 @@ private fun ContactConnectionInfoLayout( } @Composable -fun ShareLinkButton(connReqInvitation: String) { +fun ShareLinkButton(linkUri: String) { val clipboard = LocalClipboardManager.current SettingsActionItem( painterResource(MR.images.ic_share), stringResource(MR.strings.share_invitation_link), click = { chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(connChatUsed = true) - clipboard.shareText(simplexChatLink(connReqInvitation)) + clipboard.shareText(simplexChatLink(linkUri)) }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, @@ -185,8 +191,8 @@ fun DeleteButton(onClick: () -> Unit) { private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withBGApi { chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let { - withChats { - updateContactConnection(rhId, it) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, it) } } } @@ -201,13 +207,13 @@ private fun PreviewContactConnectionInfoView() { SimpleXTheme { ContactConnectionInfoLayout( chatModel = ChatModel, - connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", + connLink = CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null), + showShortLink = remember { mutableStateOf(true) }, contactConnection = PendingContactConnection.getSampleData(), focusAlias = false, rhId = null, deleteConnection = {}, onLocalAliasChanged = {}, - share = {}, learnMore = {} ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 923c0256a8..1b3138d21c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -4,6 +4,7 @@ import SectionBottomSpacer import SectionItemView import SectionTextFooter import SectionView +import SectionViewWithButton import TextIconSpaced import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -31,7 +32,6 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.topPaddingToContent @@ -39,7 +39,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.* -import java.net.URI enum class NewChatOption { INVITE, CONNECT @@ -50,17 +49,17 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC val selection = remember { stateGetOrPut("selection") { selection } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) } - val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } } + val connLinkInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connLink ?: CreatedConnLink("", null) } } val creatingConnReq = rememberSaveable { mutableStateOf(false) } val pastedLink = rememberSaveable { mutableStateOf("") } LaunchedEffect(selection.value) { if ( selection.value == NewChatOption.INVITE - && connReqInvitation.isEmpty() + && connLinkInvitation.connFullLink.isEmpty() && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(rh?.remoteHostId, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(rh?.remoteHostId, creatingConnReq, connLinkInvitation, contactConnection) } } DisposableEffect(Unit) { @@ -145,12 +144,12 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC Modifier .fillMaxWidth() .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), - verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connReqInvitation.isEmpty()) Arrangement.Center else Arrangement.Top + verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top ) { Spacer(Modifier.height(DEFAULT_PADDING)) when (index) { NewChatOption.INVITE.ordinal -> { - PrepareAndInviteView(rh?.remoteHostId, contactConnection, connReqInvitation, creatingConnReq) + PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) } NewChatOption.CONNECT.ordinal -> { ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) @@ -164,17 +163,17 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } @Composable -private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connReqInvitation: String, creatingConnReq: MutableState) { - if (connReqInvitation.isNotEmpty()) { +private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState) { + if (connLinkInvitation.connFullLink.isNotEmpty()) { InviteView( rhId, - connReqInvitation = connReqInvitation, + connLinkInvitation = connLinkInvitation, contactConnection = contactConnection, ) } else if (creatingConnReq.value) { CreatingLinkProgressView() } else { - RetryButton { createInvitation(rhId, creatingConnReq, connReqInvitation, contactConnection) } + RetryButton { createInvitation(rhId, creatingConnReq, connLinkInvitation, contactConnection) } } } @@ -187,7 +186,7 @@ private fun updateShownConnection(conn: PendingContactConnection) { chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy( conn = conn, connId = conn.id, - connReq = conn.connReqInv ?: "", + connLink = conn.connLinkInv ?: CreatedConnLink("", null), connChatUsed = true ) } @@ -315,8 +314,8 @@ fun ActiveProfilePicker( if (contactConnection != null) { updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) if (updatedConn != null) { - withChats { - updateContactConnection(rhId, updatedConn) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, updatedConn) updateShownConnection(updatedConn) } } @@ -338,8 +337,8 @@ fun ActiveProfilePicker( } if (updatedConn != null) { - withChats { - updateContactConnection(user.remoteHostId, updatedConn) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(user.remoteHostId, updatedConn) } } @@ -368,8 +367,8 @@ fun ActiveProfilePicker( appPreferences.incognito.set(true) val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) if (conn != null) { - withChats { - updateContactConnection(rhId, conn) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, conn) updateShownConnection(conn) } close() @@ -451,15 +450,21 @@ fun ActiveProfilePicker( } @Composable -private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState) { - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { - LinkTextView(connReqInvitation, true) - } - +private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState) { + val showShortLink = remember { mutableStateOf(true) } Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.or_show_this_qr_code).uppercase(), headerBottomPadding = 5.dp) { - SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() }) + SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + + SectionViewWithButton( + stringResource(MR.strings.or_show_this_qr_code).uppercase(), + titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) } Spacer(Modifier.height(DEFAULT_PADDING)) @@ -530,6 +535,18 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection } } +@Composable +fun ToggleShortLinkButton(short: MutableState) { + Text( + stringResource(if (short.value) MR.strings.full_link_button_text else MR.strings.short_link_button_text), + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { short.value = !short.value }, + style = MaterialTheme.typography.body2, fontSize = 14.sp, color = MaterialTheme.colors.primary + ) +} + @Composable fun AddContactLearnMoreButton() { IconButton( @@ -677,17 +694,17 @@ private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanu private fun createInvitation( rhId: Long?, creatingConnReq: MutableState, - connReqInvitation: String, + connLinkInvitation: CreatedConnLink, contactConnection: MutableState ) { - if (connReqInvitation.isNotEmpty() || contactConnection.value != null || creatingConnReq.value) return + if (connLinkInvitation.connFullLink.isNotEmpty() || contactConnection.value != null || creatingConnReq.value) return creatingConnReq.value = true withBGApi { val (r, alert) = controller.apiAddContact(rhId, incognito = controller.appPrefs.incognito.get()) if (r != null) { - withChats { - updateContactConnection(rhId, r.second) - chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, r.second) + chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connLink = r.first, connChatUsed = false, conn = r.second) contactConnection.value = r.second } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index e38c983487..bacb5ab802 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -12,13 +12,33 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import boofcv.alg.drawing.FiducialImageEngine import boofcv.alg.fiducial.qrcode.* -import chat.simplex.common.model.CryptoFile +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch +@Composable +fun SimpleXCreatedLinkQRCode( + connLink: CreatedConnLink, + short: Boolean, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), + tintColor: Color = Color(0xff062d56), + withLogo: Boolean = true, + onShare: (() -> Unit)? = null, +) { + QRCode( + connLink.simplexChatUri(short), + modifier, + padding, + tintColor, + withLogo, + onShare, + ) +} + @Composable fun SimpleXLinkQRCode( connReq: String, @@ -38,14 +58,6 @@ fun SimpleXLinkQRCode( ) } -fun simplexChatLink(uri: String): String { - return if (uri.startsWith("simplex:/")) { - uri.replace("simplex:/", "https://simplex.chat/") - } else { - uri - } -} - @Composable fun QRCode( connReq: String, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 5132516669..72fa45b936 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -11,13 +11,13 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.res.MR +import kotlinx.coroutines.* @Composable fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { @@ -34,8 +34,8 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { if (updated != null) { val (updatedProfile, updatedContacts) = updated m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences) - withChats { - updatedContacts.forEach { updateContact(user.remoteHostId, it) } + withContext(Dispatchers.Main) { + updatedContacts.forEach { chatModel.chatsContext.updateContact(user.remoteHostId, it) } } currentPreferences = preferences } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index c411eb0d78..24978ecf7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -30,8 +30,8 @@ import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* +import kotlinx.coroutines.* enum class LAMode { SYSTEM, @@ -88,6 +88,13 @@ fun PrivacySettingsView( simplexLinkMode.set(it) chatModel.simplexLinkMode.value = it }) + if (appPrefs.developerTools.get()) { + SettingsPreferenceItem( + null, + stringResource(MR.strings.privacy_short_links), + chatModel.controller.appPrefs.privacyShortLinks + ) + } } SectionDividerSpaced() @@ -119,15 +126,15 @@ fun PrivacySettingsView( chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) if (clearOverrides) { // For loop here is to prevent ConcurrentModificationException that happens with forEach - withChats { - for (i in 0 until chats.size) { - val chat = chats[i] + withContext(Dispatchers.Main) { + for (i in 0 until chatModel.chatsContext.chats.size) { + val chat = chatModel.chatsContext.chats[i] if (chat.chatInfo is ChatInfo.Direct) { var contact = chat.chatInfo.contact val sendRcpts = contact.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) - updateContact(currentUser.remoteHostId, contact) + chatModel.chatsContext.updateContact(currentUser.remoteHostId, contact) } } } @@ -143,16 +150,16 @@ fun PrivacySettingsView( chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) if (clearOverrides) { - withChats { + withContext(Dispatchers.Main) { // For loop here is to prevent ConcurrentModificationException that happens with forEach - for (i in 0 until chats.size) { - val chat = chats[i] + for (i in 0 until chatModel.chatsContext.chats.size) { + val chat = chatModel.chatsContext.chats[i] if (chat.chatInfo is ChatInfo.Group) { var groupInfo = chat.chatInfo.groupInfo val sendRcpts = groupInfo.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) - updateGroup(currentUser.remoteHostId, groupInfo) + chatModel.chatsContext.updateGroup(currentUser.remoteHostId, groupInfo) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 7bc35bc0de..8c7c2d8416 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -5,6 +5,7 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView +import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,7 +62,8 @@ fun UserAddressView( fun createAddress() { withBGApi { progressIndicator = true - val connReqContact = chatModel.controller.apiCreateUserAddress(user?.value?.remoteHostId) + val short = appPreferences.privacyShortLinks.get() + val connReqContact = chatModel.controller.apiCreateUserAddress(user.value?.remoteHostId, short = short) if (connReqContact != null) { chatModel.userAddress.value = UserContactLinkRec(connReqContact) @@ -102,7 +104,7 @@ fun UserAddressView( sendEmail = { userAddress -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(simplexChatLink(userAddress.connReqContact)) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink(userAddress.connLinkContact.connFullLink)) // TODO [short links] replace with short link ) }, setProfileAddress = ::setProfileAddress, @@ -198,10 +200,14 @@ private fun UserAddressLayout( } else { val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } + val showShortLink = remember { mutableStateOf(true) } - SectionView(stringResource(MR.strings.for_social_media).uppercase()) { - SimpleXLinkQRCode(userAddress.connReqContact) - ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } + SectionViewWithButton( + stringResource(MR.strings.for_social_media).uppercase(), + titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) + ShareAddressButton { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } // ShareViaEmailButton { sendEmail(userAddress) } BusinessAddressToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAas) @@ -584,7 +590,7 @@ fun PreviewUserAddressLayoutAddressCreated() { SimpleXTheme { UserAddressLayout( user = User.sampleData, - userAddress = UserContactLinkRec("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"), + userAddress = UserContactLinkRec(CreatedConnLink("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", null)), createAddress = {}, share = { _ -> }, deleteAddress = {}, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index d905ab71ea..90a176658d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -91,12 +91,14 @@ SimpleX contact address SimpleX one-time invitation SimpleX group link + SimpleX channel link via %1$s SimpleX links Description Full link Via browser Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + Use short links (BETA) Spam @@ -168,6 +170,8 @@ You are already connected to %1$s. Invalid connection link Please check that you used the correct link or ask your contact to send you another one. + Unsupported connection link + This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Connection error (AUTH) Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. Connection blocked @@ -795,6 +799,8 @@ 1-time link SimpleX address Or show this code + Full link + Short link Share profile Select chat profile Error switching profile diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 9d747206ab..dfffb826f5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -14,8 +14,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -58,12 +56,12 @@ fun showApp() { } else { // The last possible cause that can be closed withApi { - withChats { + withContext(Dispatchers.Main) { chatModel.chatId.value = null - chatItems.clearAndNotify() + chatModel.chatsContext.chatItems.clearAndNotify() } - withReportsChatsIfOpen { - chatItems.clearAndNotify() + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value = null } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index e295144191..9fd65ec995 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -51,7 +51,7 @@ private fun ActiveCallInteractiveAreaOneHand(call: Call, showMenu: MutableState< val chat = chatModel.getChat(call.contact.id) if (chat != null) { withBGApi { - openChat(chat.remoteHostId, chat.chatInfo) + openChat(secondaryChatsCtx = null, chat.remoteHostId, chat.chatInfo) } } }, @@ -116,7 +116,7 @@ private fun ActiveCallInteractiveAreaNonOneHand(call: Call, showMenu: MutableSta val chat = chatModel.getChat(call.contact.id) if (chat != null) { withBGApi { - openChat(chat.remoteHostId, chat.chatInfo) + openChat(secondaryChatsCtx = null, chat.remoteHostId, chat.chatInfo) } } }, diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 50e49c7b26..1e320972be 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.3.1 -android.version_code=281 +android.version_name=6.3.2 +android.version_code=283 -desktop.version_name=6.3.1 -desktop.version_code=97 +desktop.version_name=6.3.2 +desktop.version_code=98 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index a3b71156fb..e595c0b750 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -11,6 +11,7 @@ module Directory.Events ( DirectoryEvent (..), DirectoryCmd (..), ADirectoryCmd (..), + DirectoryHelpSection (..), DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, @@ -25,6 +26,7 @@ import qualified Data.Attoparsec.Text as A import Data.Char (isSpace) import Data.Either (fromRight) import Data.Functor (($>)) +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -45,7 +47,7 @@ data DirectoryEvent = DEContactConnected Contact | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} - | DEGroupUpdated {contactId :: ContactId, fromGroup :: GroupInfo, toGroup :: GroupInfo} + | DEGroupUpdated {member :: GroupMember, fromGroup :: GroupInfo, toGroup :: GroupInfo} | DEPendingMember GroupInfo GroupMember | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed @@ -66,7 +68,7 @@ crDirectoryEvent = \case CRContactConnected {contact} -> Just $ DEContactConnected contact CRReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} CRUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember - CRGroupUpdated {fromGroup, toGroup, member_} -> (\contactId -> DEGroupUpdated {contactId, fromGroup, toGroup}) <$> (memberContactId =<< member_) + CRGroupUpdated {fromGroup, toGroup, member_} -> (\member -> DEGroupUpdated {member, fromGroup, toGroup}) <$> member_ CRJoinedGroupMember {groupInfo, member = m} | pending m -> Just $ DEPendingMember groupInfo m | otherwise -> Nothing @@ -137,8 +139,11 @@ deriving instance Show (DirectoryCmdTag r) data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r) +data DirectoryHelpSection = DHSRegistration | DHSCommands + deriving (Show) + data DirectoryCmd (r :: DirectoryRole) where - DCHelp :: DirectoryCmd 'DRUser + DCHelp :: DirectoryHelpSection -> DirectoryCmd 'DRUser DCSearchGroup :: Text -> DirectoryCmd 'DRUser DCSearchNext :: DirectoryCmd 'DRUser DCAllGroups :: DirectoryCmd 'DRUser @@ -180,7 +185,7 @@ directoryCmdP = (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) <|> pure (ADC SDRUser DCUnknownCommand) tagP = - A.takeTill (== ' ') >>= \case + A.takeTill isSpace >>= \case "help" -> u DCHelp_ "h" -> u DCHelp_ "next" -> u DCSearchNext_ @@ -213,11 +218,19 @@ directoryCmdP = su = pure . ADCT SDRSuperUser cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) cmdP = \case - DCHelp_ -> pure DCHelp + DCHelp_ -> DCHelp . fromMaybe DHSRegistration <$> optional (A.takeWhile isSpace *> helpSectionP) + where + helpSectionP = + A.takeText >>= \case + "registration" -> pure DHSRegistration + "r" -> pure DHSRegistration + "commands" -> pure DHSCommands + "c" -> pure DHSCommands + _ -> fail "bad help section" DCSearchNext_ -> pure DCSearchNext DCAllGroups_ -> pure DCAllGroups DCRecentGroups_ -> pure DCRecentGroups - DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (A.takeWhile1 isSpace *> A.takeText) + DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (spacesP *> A.takeText) DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup DCListUserGroups_ -> pure DCListUserGroups DCDeleteGroup_ -> gc DCDeleteGroup @@ -228,7 +241,7 @@ directoryCmdP = DCGroupFilter_ -> do (groupId, displayName_) <- gc_ (,) acceptance_ <- - (A.takeWhile (== ' ') >> A.endOfInput) $> Nothing + (A.takeWhile isSpace >> A.endOfInput) $> Nothing <|> Just <$> (acceptancePresetsP <|> acceptanceFiltersP) pure $ DCGroupFilter groupId displayName_ acceptance_ where @@ -272,15 +285,15 @@ directoryCmdP = where gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) - -- wordP = spacesP *> A.takeTill (== ' ') - spacesP = A.takeWhile1 (== ' ') + -- wordP = spacesP *> A.takeTill isSpace + spacesP = A.takeWhile1 isSpace viewName :: Text -> Text -viewName n = if T.any (== ' ') n then "'" <> n <> "'" else n +viewName n = if T.any isSpace n then "'" <> n <> "'" else n directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case - DCHelp -> "help" + DCHelp _ -> "help" DCSearchGroup _ -> "search" DCSearchNext -> "next" DCAllGroups -> "all" diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index d10656c750..e547a1e982 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -48,6 +48,7 @@ import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core +import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) @@ -60,7 +61,9 @@ import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..)) import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), sameConnReqContact, sameShortLinkContact) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) @@ -185,13 +188,13 @@ useMemberFilter img_ = \case Nothing -> False readBlockedWordsConfig :: DirectoryOpts -> IO BlockedWordsConfig -readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, nameSpellingFile, blockedExtensionRules} = do +readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, nameSpellingFile, blockedExtensionRules, testing} = do extensionRules <- maybe (pure []) (fmap read . readFile) blockedExtensionRules spelling <- maybe (pure M.empty) (fmap (M.fromList . read) . readFile) nameSpellingFile blockedFragments <- S.fromList <$> maybe (pure []) (fmap T.lines . T.readFile) blockedFragmentsFile bws <- maybe (pure []) (fmap lines . readFile) blockedWordsFile let blockedWords = S.fromList $ concatMap (wordVariants extensionRules) bws - putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) + unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () @@ -200,7 +203,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner - DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup + DEGroupUpdated {member, fromGroup, toGroup} -> deGroupUpdated member fromGroup toGroup DEPendingMember g m -> dePendingMember g m DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role @@ -253,17 +256,25 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = getGroups fullName >>= mapM duplicateGroup where - sameGroup (GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}}, _) = - gId /= groupId && n == displayName && fn == fullName + sameGroupNotRemoved (g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}}, _) = + gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) duplicateGroup [] = pure DGUnique duplicateGroup groups = do - let gs = filter sameGroup groups + let gs = filter sameGroupNotRemoved groups if null gs then pure DGUnique else do (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) let reserved = any (\(GroupInfo {groupId = gId}, _) -> gId `S.member` lgs || gId `S.member` rgs) gs - pure $ if reserved then DGReserved else DGRegistered + if reserved + then pure DGReserved + else do + removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs + pure $ if removed then DGUnique else DGRegistered + isGroupRemoved (GroupInfo {groupId = gId}, _) = + getGroupReg st gId >>= \case + Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus + Nothing -> pure True processInvitation :: Contact -> GroupInfo -> IO () processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do @@ -337,15 +348,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName setGroupRegOwner st gr owner let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - CRGroupLinkCreated {connReqContact} -> do + sendChatCmd cc (APICreateGroupLink groupId GRMember False) >>= \case + CRGroupLinkCreated {connLinkContact = CCLink gLink _} -> do setGroupStatus st gr GRSPendingUpdate notifyOwner gr "Created the public link to join the group via this directory service that is always online.\n\n\ \Please add it to the group welcome message.\n\ \For example, add:" - notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact connReqContact) + notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact gLink) CRChatCmdError _ (ChatError e) -> case e of CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." @@ -354,78 +365,97 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> notifyOwner gr $ unexpectedError "can't create group link" _ -> notifyOwner gr $ unexpectedError "can't create group link" - deGroupUpdated :: ContactId -> GroupInfo -> GroupInfo -> IO () - deGroupUpdated ctId fromGroup toGroup = do + deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () + deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do withGroupReg toGroup "group updated" $ \gr -> do let userGroupRef = userGroupReference gr toGroup + byMember = case memberContactId m of + Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. + _ -> " by " <> mName -- owner notification from directory will include the name. readTVarIO (groupRegStatus gr) >>= \case GRSPendingConfirmation -> pure () GRSProposed -> pure () GRSPendingUpdate -> groupProfileUpdate >>= \case GPNoServiceLink -> - when (ctId `isOwner` gr) $ notifyOwner gr $ "The profile updated for " <> userGroupRef <> ", but the group link is not added to the welcome message." - GPServiceLinkAdded - | ctId `isOwner` gr -> groupLinkAdded gr - | otherwise -> notifyOwner gr "The group link is added by another group member, your registration will not be processed.\n\nPlease update the group profile yourself." - GPServiceLinkRemoved -> when (ctId `isOwner` gr) $ notifyOwner gr $ "The group link of " <> userGroupRef <> " is removed from the welcome message, please add it." - GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr + notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message." + GPServiceLinkAdded -> groupLinkAdded gr byMember + GPServiceLinkRemoved -> + notifyOwner gr $ + "The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it." + GPHasServiceLink -> groupLinkAdded gr byMember GPServiceLinkError -> do - when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers." + notifyOwner gr $ + ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) + <> " after profile was updated" <> byMember <> ". Please report the error to the developers." logError $ "Error: no group link for " <> userGroupRef - GRSPendingApproval n -> processProfileChange gr $ n + 1 - GRSActive -> processProfileChange gr 1 - GRSSuspended -> processProfileChange gr 1 - GRSSuspendedBadRoles -> processProfileChange gr 1 + GRSPendingApproval n -> processProfileChange gr byMember $ n + 1 + GRSActive -> processProfileChange gr byMember 1 + GRSSuspended -> processProfileChange gr byMember 1 + GRSSuspendedBadRoles -> processProfileChange gr byMember 1 GRSRemoved -> pure () where - isInfix l d_ = l `T.isInfixOf` fromMaybe "" d_ GroupInfo {groupId, groupProfile = p} = fromGroup GroupInfo {groupProfile = p'} = toGroup sameProfile GroupProfile {displayName = n, fullName = fn, image = i, description = d} GroupProfile {displayName = n', fullName = fn', image = i', description = d'} = n == n' && fn == fn' && i == i' && d == d' - groupLinkAdded gr = do + groupLinkAdded gr byMember = do getDuplicateGroup toGroup >>= \case Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup _ -> do let gaId = 1 setGroupStatus st gr $ GRSPendingApproval gaId - notifyOwner gr $ "Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message.\nYou will be notified once the group is added to the directory - it may take up to 48 hours." + notifyOwner gr $ + ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) + <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." checkRolesSendToApprove gr gaId - processProfileChange gr n' = do + processProfileChange gr byMember n' = do setGroupStatus st gr GRSPendingUpdate let userGroupRef = userGroupReference gr toGroup groupRef = groupReference toGroup groupProfileUpdate >>= \case GPNoServiceLink -> do - notifyOwner gr $ "The group profile is updated " <> userGroupRef <> ", but no link is added to the welcome message.\n\nThe group will remain hidden from the directory until the group link is added and the group is re-approved." + notifyOwner gr $ + ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") + <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." GPServiceLinkRemoved -> do - notifyOwner gr $ "The group link for " <> userGroupRef <> " is removed from the welcome message.\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." + notifyOwner gr $ + ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) + <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." GPServiceLinkAdded -> do setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ "The group link is added to " <> userGroupRef <> "!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group link is added to " <> groupRef <> "." + notifyOwner gr $ + ("The group link is added to " <> userGroupRef <> byMember) + <> "!\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." checkRolesSendToApprove gr n' GPHasServiceLink -> do setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group " <> groupRef <> " is updated." + notifyOwner gr $ + ("The group " <> userGroupRef <> " is updated" <> byMember) + <> "!\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." checkRolesSendToApprove gr n' GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where profileUpdate = \case - CRGroupLink {connReqContact} -> - let groupLink1 = strEncodeTxt connReqContact - groupLink2 = strEncodeTxt $ simplexChatContact connReqContact - hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p - hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p' + CRGroupLink {connLinkContact = CCLink cr sl_} -> + let hadLinkBefore = profileHasGroupLink fromGroup + hasLinkNow = profileHasGroupLink toGroup + profileHasGroupLink GroupInfo {groupProfile = gp} = + maybe False (any ftHasLink) $ parseMaybeMarkdownList =<< description gp + ftHasLink = \case + FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of + CLFull cr' -> sameConnReqContact cr' cr + CLShort sl' -> maybe False (sameShortLinkContact sl') sl_ + _ -> False in if | hadLinkBefore && hasLinkNow -> GPHasServiceLink | hadLinkBefore -> GPServiceLinkRemoved @@ -619,7 +649,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () deUserCommand ct ciId = \case - DCHelp -> + DCHelp DHSRegistration -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ \1. Invite " @@ -630,7 +660,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> " bot will create a public group link for the new members to join even when you are offline.\n\ \3. You will then need to add this link to the group welcome message.\n\ \4. Once the link is added, service admins will approve the group (it can take up to 48 hours), and everybody will be able to find it in directory.\n\n\ - \Start from inviting the bot to your group as admin - it will guide you through the process" + \Start from inviting the bot to your group as admin - it will guide you through the process." + DCHelp DHSCommands -> + sendMessage cc ct $ + "*/help commands* - receive this help message.\n\ + \*/help* - how to register your group to be added to directory.\n\ + \*/list* - list the groups you registered.\n\ + \*/delete :* - remove the group you submitted from directory, with _ID_ and _name_ as shown by */list* command.\n\ + \*/role * - view and set default member role for your group.\n\ + \*/filter * - view and set spam filter settings for group.\n\n\ + \To search for groups, send the search text." DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case @@ -669,17 +708,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName "0 registered groups for " <> localDisplayName' ct <> " (" <> tshow (contactId' ct) <> ") out of " <> tshow total <> " registrations" void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} -> sendGroupInfo ct gr userGroupRegId Nothing - DCDeleteGroup ugrId gName -> - withUserGroupReg ugrId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do + DCDeleteGroup gId gName -> + (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do delGroupReg st gr - sendReply $ "Your group " <> displayName <> " is deleted from the directory" + sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" DCMemberRole gId gName_ mRole_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of Nothing -> getGroupLinkRole cc user g >>= \case - Just (_, gLink, mRole) -> do + Just (_, CCLink gLink _, mRole) -> do let anotherRole = case mRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n mRole @@ -804,9 +843,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ - (approved <> " and listed in directory!\n") + (approved <> " and listed in directory - please moderate it!\n") <> "Please note: if you change the group profile it will be hidden from directory until it is re-approved.\n\n" - <> ("Use */filter " <> tshow ugrId <> "* to configure anti-spam filter and */role " <> tshow ugrId <> "* to set default member role.") + <> "Supported commands:\n" + <> ("- */filter " <> tshow ugrId <> "* - to configure anti-spam filter.\n") + <> ("- */role " <> tshow ugrId <> "* - to set default member role.\n") + <> "- */help commands* - other commands." invited <- forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do inviteToOwnersGroup og gr $ \case @@ -856,10 +898,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ _ -> sendChatCmd cc (APIGetGroupLink groupId) >>= \case - CRGroupLink {connReqContact, memberRole} -> + CRGroupLink {connLinkContact = CCLink cReq _, memberRole} -> sendReply $ T.unlines [ "The link to join the group " <> groupRef <> ":", - strEncodeTxt $ simplexChatContact connReqContact, + strEncodeTxt $ simplexChatContact cReq, "New member role: " <> strEncodeTxt memberRole ] CRChatCmdError _ (ChatErrorStore (SEGroupLinkNotFound _)) -> @@ -1002,7 +1044,7 @@ vr :: ChatController -> VersionRangeChat vr ChatController {config = ChatConfig {chatVRange}} = chatVRange {-# INLINE vr #-} -getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, ConnReqContact, GroupMemberRole)) +getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, CreatedLinkContact, GroupMemberRole)) getGroupLinkRole cc user gInfo = withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo @@ -1010,7 +1052,7 @@ setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where resp = \case - CRGroupLink _ _ gLink _ -> Just gLink + CRGroupLink _ _ (CCLink gLink _) _ -> Just gLink _ -> Nothing unexpectedError :: Text -> Text diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index fed52f494f..031d05fd49 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -25,6 +25,7 @@ module Directory.Store filterListedGroups, groupRegStatusText, pendingApproval, + groupRemoved, fromCustomData, toCustomData, noJoinFilter, @@ -139,13 +140,19 @@ data GroupRegStatus | GRSSuspended | GRSSuspendedBadRoles | GRSRemoved + deriving (Show) pendingApproval :: GroupRegStatus -> Bool pendingApproval = \case GRSPendingApproval _ -> True _ -> False -data DirectoryStatus = DSListed | DSReserved | DSRegistered +groupRemoved :: GroupRegStatus -> Bool +groupRemoved = \case + GRSRemoved -> True + _ -> False + +data DirectoryStatus = DSListed | DSReserved | DSRegistered | DSRemoved groupRegStatusText :: GroupRegStatus -> Text groupRegStatusText = \case @@ -163,6 +170,7 @@ grDirectoryStatus = \case GRSActive -> DSListed GRSSuspended -> DSReserved GRSSuspendedBadRoles -> DSReserved + GRSRemoved -> DSRemoved _ -> DSRegistered $(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition) @@ -200,8 +208,9 @@ addGroupReg st ct GroupInfo {groupId} grStatus = do | otherwise = mx delGroupReg :: DirectoryStore -> GroupReg -> IO () -delGroupReg st GroupReg {dbGroupId = gId} = do +delGroupReg st GroupReg {dbGroupId = gId, groupRegStatus} = do logGDelete st gId + atomically $ writeTVar groupRegStatus GRSRemoved atomically $ unlistGroup st gId atomically $ modifyTVar' (groupRegs st) $ filter ((gId ==) . dbGroupId) @@ -216,6 +225,7 @@ setGroupStatus st gr grStatus = do DSListed -> listGroup DSReserved -> reserveGroup DSRegistered -> unlistGroup + DSRemoved -> unlistGroup setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () setGroupRegOwner st gr owner = do @@ -390,6 +400,7 @@ mkDirectoryStore h groups = DSListed -> (grs', S.insert gId listed, reserved) DSReserved -> (grs', listed, S.insert gId reserved) DSRegistered -> (grs', listed, reserved) + DSRemoved -> (grs, listed, reserved) mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore mkDirectoryStore_ h (grs, listed, reserved) = do diff --git a/cabal.project b/cabal.project index 0554d14ec8..1bf1289900 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: aace3fd2fb146097304b09ef07ff613a8b255f67 + tag: 305f79d2a66a8d122bf457e023988200bb7fe00c source-repository-package type: git diff --git a/docs/SERVER.md b/docs/SERVER.md index 4ddfb68e63..57f49b5588 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -531,7 +531,7 @@ To verify server binaries after you downloaded them: 3. Import the key with `gpg --import FB44AF81A45BDE327319797C85107E357D4A17FC`. Key filename should be the same as its fingerprint, but please change it if necessary. -4. Run `gpg --verify --trusted-key _sha256sums.asc _sha256sums`. It should print: +4. Run `gpg --verify _sha256sums.asc _sha256sums`. It should print: > Good signature from "SimpleX Chat " diff --git a/scripts/ci/linux_util_free_space.sh b/scripts/ci/linux_util_free_space.sh new file mode 100755 index 0000000000..ef00eb886e --- /dev/null +++ b/scripts/ci/linux_util_free_space.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# +# Taken from: https://github.com/apache/arrow/blob/main/ci/scripts/util_free_space.sh + +set -eux + +df -h +echo "::group::/usr/local/*" +du -hsc /usr/local/* +echo "::endgroup::" +# ~1GB +sudo rm -rf \ + /usr/local/aws-sam-cil \ + /usr/local/julia* || : +echo "::group::/usr/local/bin/*" +du -hsc /usr/local/bin/* +echo "::endgroup::" +# ~1GB (From 1.2GB to 214MB) +sudo rm -rf \ + /usr/local/bin/aliyun \ + /usr/local/bin/azcopy \ + /usr/local/bin/bicep \ + /usr/local/bin/cmake-gui \ + /usr/local/bin/cpack \ + /usr/local/bin/helm \ + /usr/local/bin/hub \ + /usr/local/bin/kubectl \ + /usr/local/bin/minikube \ + /usr/local/bin/node \ + /usr/local/bin/packer \ + /usr/local/bin/pulumi* \ + /usr/local/bin/sam \ + /usr/local/bin/stack \ + /usr/local/bin/terraform || : +# 142M +sudo rm -rf /usr/local/bin/oc || : \ +echo "::group::/usr/local/share/*" +du -hsc /usr/local/share/* +echo "::endgroup::" +# 506MB +sudo rm -rf /usr/local/share/chromium || : +# 1.3GB +sudo rm -rf /usr/local/share/powershell || : +echo "::group::/usr/local/lib/*" +du -hsc /usr/local/lib/* +echo "::endgroup::" +# 15GB +sudo rm -rf /usr/local/lib/android || : +# 341MB +sudo rm -rf /usr/local/lib/heroku || : +# 1.2GB +sudo rm -rf /usr/local/lib/node_modules || : +echo "::group::/opt/*" +du -hsc /opt/* +echo "::endgroup::" +# 679MB +sudo rm -rf /opt/az || : +echo "::group::/opt/microsoft/*" +du -hsc /opt/microsoft/* +echo "::endgroup::" +# 197MB +sudo rm -rf /opt/microsoft/powershell || : +echo "::group::/opt/hostedtoolcache/*" +du -hsc /opt/hostedtoolcache/* +echo "::endgroup::" +# 5.3GB +sudo rm -rf /opt/hostedtoolcache/CodeQL || : +# 1.4GB +sudo rm -rf /opt/hostedtoolcache/go || : +# 489MB +sudo rm -rf /opt/hostedtoolcache/PyPy || : +# 376MB +sudo rm -rf /opt/hostedtoolcache/node || : +# Remove Web browser packages +sudo apt purge -y \ + firefox \ + google-chrome-stable \ + microsoft-edge-stable +df -h diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 6ad4fda03e..823b8562fc 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,28 @@ + + https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +

New in v6.3.1-2:

+
    +
  • fix related to backward/forward compatibility of the app in some rare cases.
  • +
  • scrolling/navigation improvements.
  • +
  • faster onboarding (conditions and operators are combined to one screen).
  • +
+

New in v6.3.0:

+
    +
  • Mention members and get notified when mentioned.
  • +
  • Send private reports to moderators.
  • +
  • Delete, block and change role for multiple members at once
  • +
  • Faster sending messages and faster deletion.
  • +
  • Organize chats into lists to keep track of what's important.
  • +
  • Jump to found and forwarded messages.
  • +
  • Private media file names.
  • +
  • Message expiration in chats.
  • +
+
+
https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 41b45e9ddc..e2df71ae0f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."aace3fd2fb146097304b09ef07ff613a8b255f67" = "0iqdarkvlakk4xmrqsg62z0vhs3kwm02l8vpr383vf8q2hd7ky75"; + "https://github.com/simplex-chat/simplexmq.git"."305f79d2a66a8d122bf457e023988200bb7fe00c" = "1lawc5pf4hgc6wym2xz8gi92izi1vk98ppv3ldrpajz1mq62ifpc"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/scripts/reproduce-builds.sh b/scripts/reproduce-builds.sh new file mode 100644 index 0000000000..1334fec0ec --- /dev/null +++ b/scripts/reproduce-builds.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env sh +set -eu + +TAG="$1" + +tempdir="$(mktemp -d)" +init_dir="$PWD" + +repo_name="simplex-chat" +repo="https://github.com/simplex-chat/${repo_name}" + +cabal_local='ignore-project: False +package direct-sqlcipher + flags: +openssl' + +export DOCKER_BUILDKIT=1 + +cleanup() { + docker exec -t builder sh -c 'rm -rf ./dist-newstyle' 2>/dev/null || : + rm -rf -- "$tempdir" + docker rm --force builder 2>/dev/null || : + docker image rm local 2>/dev/null || : + cd "$init_dir" +} +trap 'cleanup' EXIT INT + +mkdir -p "$init_dir/$TAG/from-source" "$init_dir/$TAG/prebuilt" + +git -C "$tempdir" clone "$repo.git" &&\ + cd "$tempdir/${repo_name}" &&\ + git checkout "$TAG" + +for os in 20.04 22.04; do + os_url="$(printf '%s' "$os" | tr '.' '_')" + + # Build image + docker build \ + --no-cache \ + --build-arg TAG=${os} \ + --build-arg GHC=9.6.3 \ + -f "$tempdir/${repo_name}/Dockerfile.build" \ + -t local \ + . + + printf '%s' "$cabal_local" > "$tempdir/${repo_name}/cabal.project.local" + + # Run container in background + docker run -t -d \ + --name builder \ + -v "$tempdir/${repo_name}:/project" \ + local + + docker exec \ + -t \ + builder \ + sh -c 'cabal clean && cabal update && cabal build -j --enable-tests && mkdir -p /out && for i in simplex-chat; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) && chmod +x "$bin" && mv "$bin" /out/; done && strip /out/simplex-chat' + + docker cp \ + builder:/out/simplex-chat \ + "$init_dir/$TAG/from-source/simplex-chat-ubuntu-${os_url}-x86-64" + + # Download prebuilt postgresql binary + curl -L \ + --output-dir "$init_dir/$TAG/prebuilt/" \ + -O \ + "$repo/releases/download/${TAG}/simplex-chat-ubuntu-${os_url}-x86-64" + + # Important! Remove dist-newstyle for the next interation + docker exec \ + -t \ + builder \ + sh -c 'rm -rf ./dist-newstyle' + + # Also restore git to previous state + git reset --hard && git clean -dfx + + # Stop containers, delete images + docker stop builder + docker rm --force builder + docker image rm local +done + +# Cleanup +rm -rf -- "$tempdir" +cd "$init_dir" + +# Final stage: compare hashes + +# Path to binaries +path_bin="$init_dir/$TAG" + +# Assume everything is okay for now +bad=0 + +# Check hashes for all binaries +for file in "$path_bin"/from-source/*; do + # Extract binary name + app="$(basename $file)" + + # Compute hash for compiled binary + compiled=$(sha256sum "$path_bin/from-source/$app" | awk '{print $1}') + # Compute hash for prebuilt binary + prebuilt=$(sha256sum "$path_bin/prebuilt/$app" | awk '{print $1}') + + # Compare + if [ "$compiled" != "$prebuilt" ]; then + # If hashes doesn't match, set bad... + bad=1 + + # ... and print affected binary + printf "%s - sha256sum hash doesn't match\n" "$app" + fi +done + +# If everything is still okay, compute checksums file +if [ "$bad" = 0 ]; then + sha256sum "$path_bin"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' > "$path_bin/_sha256sums" + + printf 'Checksums computed - %s\n' "$path_bin/_sha256sums" +fi diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 26784e13fb..665763de51 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.3.1.1 +version: 6.3.2.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -55,6 +55,7 @@ library Simplex.Chat.Mobile.WebRTC Simplex.Chat.Operators Simplex.Chat.Operators.Conditions + Simplex.Chat.Operators.Presets Simplex.Chat.Options Simplex.Chat.Options.DB Simplex.Chat.ProfileGenerator @@ -102,6 +103,7 @@ library Simplex.Chat.Options.Postgres Simplex.Chat.Store.Postgres.Migrations Simplex.Chat.Store.Postgres.Migrations.M20241220_initial + Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links else exposed-modules: Simplex.Chat.Archive @@ -231,7 +233,8 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes - Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope + Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links + Simplex.Chat.Store.SQLite.Migrations.M20250403_group_scope other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 001e2fde1b..02a765bb19 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -30,6 +30,7 @@ import Data.Time.Clock (getCurrentTime) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators +import Simplex.Chat.Operators.Presets import Simplex.Chat.Options import Simplex.Chat.Options.DB import Simplex.Chat.Protocol @@ -39,7 +40,7 @@ import Simplex.Chat.Types import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -51,34 +52,6 @@ import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E import UnliftIO.STM -operatorSimpleXChat :: NewServerOperator -operatorSimpleXChat = - ServerOperator - { operatorId = DBNewEntity, - operatorTag = Just OTSimplex, - tradeName = "SimpleX Chat", - legalName = Just "SimpleX Chat Ltd", - serverDomains = ["simplex.im"], - conditionsAcceptance = CARequired Nothing, - enabled = True, - smpRoles = allRoles, - xftpRoles = allRoles - } - -operatorFlux :: NewServerOperator -operatorFlux = - ServerOperator - { operatorId = DBNewEntity, - operatorTag = Just OTFlux, - tradeName = "Flux", - legalName = Just "InFlux Technologies Limited", - serverDomains = ["simplexonflux.com"], - conditionsAcceptance = CARequired Nothing, - enabled = False, - smpRoles = ServerRoles {storage = False, proxy = True}, - xftpRoles = ServerRoles {storage = False, proxy = True} - } - defaultChatConfig :: ChatConfig defaultChatConfig = ChatConfig @@ -112,6 +85,10 @@ defaultChatConfig = ntf = _defaultNtfServers, netCfg = defaultNetworkConfig }, + -- please note: if these servers are changed, this option needs to be split to two, + -- to have a different set of servers on the receiving end and on the sending end. + -- To preserve backward compatibility receiving end should update before the sending. + shortLinkPresetServers = allPresetServers, tbqSize = 1024, fileChunkSize = 15780, -- do not change xftpDescrPartSize = 14000, @@ -133,53 +110,6 @@ defaultChatConfig = chatHooks = defaultChatHooks } -simplexChatSMPServers :: [NewUserServer 'PSMP] -simplexChatSMPServers = - map - (presetServer True) - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", - "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", - "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", - "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", - "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", - "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", - "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", - "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", - "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" - ] - <> map - (presetServer False) - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" - ] - -fluxSMPServers :: [NewUserServer 'PSMP] -fluxSMPServers = - map - (presetServer True) - [ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion", - "smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion", - "smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion", - "smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion", - "smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion", - "smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion" - ] - -fluxXFTPServers :: [NewUserServer 'PXFTP] -fluxXFTPServers = - map - (presetServer True) - [ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion", - "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion", - "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion", - "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion", - "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", - "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" - ] - logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index bbfce15b3f..8a5a99fedb 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -15,6 +15,7 @@ import qualified Data.ByteString.Char8 as B import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M +import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Controller @@ -24,6 +25,7 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store import Simplex.Chat.Types (Contact (..), ContactId, IsContact (..), User (..)) +import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..)) import Simplex.Messaging.Encoding.String (strEncode) import System.Exit (exitFailure) @@ -49,16 +51,18 @@ initializeBotAddress = initializeBotAddress' True initializeBotAddress' :: Bool -> ChatController -> IO () initializeBotAddress' logAddress cc = do sendChatCmd cc ShowMyAddress >>= \case - CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact + CRUserContactLink _ UserContactLink {connLinkContact} -> showBotAddress connLinkContact CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do when logAddress $ putStrLn "No bot address, creating..." - sendChatCmd cc CreateMyAddress >>= \case - CRUserContactLinkCreated _ uri -> showBotAddress uri + -- TODO [short links] create short link by default + sendChatCmd cc (CreateMyAddress False) >>= \case + CRUserContactLinkCreated _ ccLink -> showBotAddress ccLink _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where - showBotAddress uri = do - when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) + showBotAddress (CCLink uri shortUri) = do + when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (maybe (strEncode uri) strEncode shortUri) + when (isJust shortUri) $ putStrLn $ "Full contact address for old clients: " <> B.unpack (strEncode uri) void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {businessAddress = False, acceptIncognito = False, autoReply = Nothing} sendMessage :: ChatController -> Contact -> Text -> IO () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f3325d261d..220e5803a3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -138,6 +138,7 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, + shortLinkPresetServers :: NonEmpty SMPServer, tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -366,7 +367,7 @@ data ChatCommand -- | APIDeleteGroupConversations GroupId (NonEmpty GroupConversationId) -- | APIArchiveGroupConversations GroupId (NonEmpty GroupConversationId) | APIUpdateGroupProfile GroupId GroupProfile - | APICreateGroupLink GroupId GroupMemberRole + | APICreateGroupLink GroupId GroupMemberRole CreateShortLink | APIGroupLinkMemberRole GroupId GroupMemberRole | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId @@ -439,21 +440,21 @@ data ChatCommand | EnableGroupMember GroupName ContactName | ChatHelp HelpSection | Welcome - | APIAddContact UserId IncognitoEnabled - | AddContact IncognitoEnabled + | APIAddContact UserId CreateShortLink IncognitoEnabled + | AddContact CreateShortLink IncognitoEnabled | APISetConnectionIncognito Int64 IncognitoEnabled | APIChangeConnectionUser Int64 UserId -- new user id to switch connection to - | APIConnectPlan UserId AConnectionRequestUri - | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) - | Connect IncognitoEnabled (Maybe AConnectionRequestUri) + | APIConnectPlan UserId AConnectionLink + | APIConnect UserId IncognitoEnabled (Maybe ACreatedConnLink) + | Connect IncognitoEnabled (Maybe AConnectionLink) | APIConnectContactViaAddress UserId IncognitoEnabled ContactId | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | DeleteContact ContactName ChatDeleteMode | ClearContact ContactName | APIListContacts UserId | ListContacts - | APICreateMyAddress UserId - | CreateMyAddress + | APICreateMyAddress UserId CreateShortLink + | CreateMyAddress CreateShortLink | APIDeleteMyAddress UserId | DeleteMyAddress | APIShowMyAddress UserId @@ -495,7 +496,7 @@ data ChatCommand | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName - | CreateGroupLink GroupName GroupMemberRole + | CreateGroupLink GroupName GroupMemberRole CreateShortLink | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName | ShowGroupLink GroupName @@ -680,10 +681,10 @@ data ChatResponse | CRUserProfileNoChange {user :: User} | CRUserPrivacy {user :: User, updatedUser :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} - | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} + | CRInvitation {user :: User, connLinkInvitation :: CreatedLinkInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} | CRConnectionUserChanged {user :: User, fromConnection :: PendingContactConnection, toConnection :: PendingContactConnection, newUser :: User} - | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} + | CRConnectionPlan {user :: User, connLink :: ACreatedConnLink, connectionPlan :: ConnectionPlan} | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} @@ -693,7 +694,7 @@ data ChatResponse | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} - | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} + | CRUserContactLinkCreated {user :: User, connLinkContact :: CreatedLinkContact} | CRUserContactLinkDeleted {user :: User} | CRReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} | CRAcceptingContactRequest {user :: User, contact :: Contact} @@ -773,8 +774,8 @@ data ChatResponse | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} | CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI - | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} - | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} + | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} + | CRGroupLink {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI @@ -949,6 +950,7 @@ data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + | CPError {chatError :: ChatError} deriving (Show) data InvitationLinkPlan @@ -992,6 +994,7 @@ connectionPlanProceed = \case GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True _ -> False + CPError _ -> True data ForwardConfirmation = FCFilesNotAccepted {fileIds :: [FileTransferId]} @@ -1255,8 +1258,8 @@ data ChatErrorType | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged - | CEConnectionPlan {connectionPlan :: ConnectionPlan} | CEInvalidConnReq + | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotReady {contact :: Contact} @@ -1591,8 +1594,6 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) @@ -1607,6 +1608,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) + $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) $(JQ.deriveJSON defaultJSON ''ContactSubStatus) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 781a46270b..9d064ea691 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1666,16 +1666,18 @@ processChatCommand' vr = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - APIAddContact userId incognito -> withUserId userId $ \user -> procCmd $ do + APIAddContact userId short incognito -> withUserId userId $ \user -> procCmd $ do -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOn subMode + let userData = shortLinkUserData short + (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation userData Nothing IKPQOn subMode + ccLink' <- shortenCreatedLink ccLink -- TODO PQ pass minVersion from the current range - conn <- withFastStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode initialChatVersion PQSupportOn - pure $ CRInvitation user cReq conn - AddContact incognito -> withUser $ \User {userId} -> - processChatCommand $ APIAddContact userId incognito + conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' ConnNew incognitoProfile subMode initialChatVersion PQSupportOn + pure $ CRInvitation user ccLink' conn + AddContact short incognito -> withUser $ \User {userId} -> + processChatCommand $ APIAddContact userId short incognito APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do conn'_ <- withFastStore $ \db -> do conn@PendingContactConnection {pccConnStatus, customUserProfileId} <- getPendingContactConnection db userId connId @@ -1693,9 +1695,9 @@ processChatCommand' vr = \case Nothing -> throwChatError CEConnectionIncognitoChangeProhibited APIChangeConnectionUser connId newUserId -> withUser $ \user@User {userId} -> do conn <- withFastStore $ \db -> getPendingContactConnection db userId connId - let PendingContactConnection {pccConnStatus, connReqInv} = conn - case (pccConnStatus, connReqInv) of - (ConnNew, Just cReqInv) -> do + let PendingContactConnection {pccConnStatus, connLinkInv} = conn + case (pccConnStatus, connLinkInv) of + (ConnNew, Just (CCLink cReqInv _)) -> do newUser <- privateGetUser newUserId conn' <- ifM (canKeepLink cReqInv newUser) (updateConnRecord user conn newUser) (recreateConn user conn newUser) pure $ CRConnectionUserChanged user conn conn' newUser @@ -1716,19 +1718,21 @@ processChatCommand' vr = \case forM_ customUserProfileId $ \profileId -> deletePCCIncognitoProfile db user profileId pure conn' - recreateConn user conn@PendingContactConnection {customUserProfileId} newUser = do + recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode - (agConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId newUser) True SCMInvitation Nothing IKPQOn subMode + let userData = shortLinkUserData $ isJust $ connShortLink =<< connLinkInv + (agConnId, ccLink) <- withAgent $ \a -> createConnection a (aUserId newUser) True SCMInvitation userData Nothing IKPQOn subMode + ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do deleteConnectionRecord db user connId forM_ customUserProfileId $ \profileId -> deletePCCIncognitoProfile db user profileId - createDirectConnection db newUser agConnId cReq ConnNew Nothing subMode initialChatVersion PQSupportOn + createDirectConnection db newUser agConnId ccLink' ConnNew Nothing subMode initialChatVersion PQSupportOn deleteAgentConnectionAsync user (aConnId' conn) pure conn' - APIConnectPlan userId cReqUri -> withUserId userId $ \user -> - CRConnectionPlan user <$> connectPlan user cReqUri - APIConnect userId incognito (Just (ACR SCMInvitation cReq@(CRInvitationUri crData e2e))) -> withUserId userId $ \user -> withInvitationLock "connect" (strEncode cReq) . procCmd $ do + APIConnectPlan userId cLink -> withUserId userId $ \user -> + uncurry (CRConnectionPlan user) <$> connectPlan user cLink + APIConnect userId incognito (Just (ACCL SCMInvitation (CCLink cReq@(CRInvitationUri crData e2e) sLnk_))) -> withUserId userId $ \user -> withInvitationLock "connect" (strEncode cReq) . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -1751,7 +1755,8 @@ processChatCommand' vr = \case where joinNewConn chatV dm = do connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' - pcc <- withFastStore' $ \db -> createDirectConnection db user connId cReq ConnPrepared (incognitoProfile $> profileToSend) subMode chatV pqSup' + let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ + pcc <- withFastStore' $ \db -> createDirectConnection db user connId ccLink ConnPrepared (incognitoProfile $> profileToSend) subMode chatV pqSup' joinPreparedConn connId pcc dm joinPreparedConn connId pcc@PendingContactConnection {pccConnId} dm = do void $ withAgent $ \a -> joinConnection a (aUserId user) connId True cReq dm pqSup' subMode @@ -1761,43 +1766,40 @@ processChatCommand' vr = \case ( CRInvitationUri crData {crScheme = SSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) - APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq + APIConnect userId incognito (Just (ACCL SCMContact ccLink)) -> withUserId userId $ \user -> connectViaContact user incognito ccLink APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq - Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do - plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) - case plan of - CPContactAddress (CAPContactViaAddress Contact {contactId}) -> - processChatCommand $ APIConnectContactViaAddress userId incognito contactId - _ -> processChatCommand $ APIConnect userId incognito aCReqUri + Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do + (ccLink, plan) <- connectPlan user cLink `catchChatError` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink ILPOk); _ -> throwError e + connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db vr user contactId when (isJust activeConn) $ throwChatError (CECommandError "contact already has connection") - case contactLink of - Just cReq -> connectContactViaAddress user incognito ct cReq + ccLink <- case contactLink of + Just (CLFull cReq) -> pure $ CCLink cReq Nothing + Just (CLShort sLnk) -> do + cReq <- getShortLinkConnReq user sLnk + pure $ CCLink cReq $ Just sLnk Nothing -> throwChatError (CECommandError "no address in contact profile") - ConnectSimplex incognito -> withUser $ \user@User {userId} -> do - let cReqUri = ACR SCMContact adminContactReq - plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) - case plan of - CPContactAddress (CAPContactViaAddress Contact {contactId}) -> - processChatCommand $ APIConnectContactViaAddress userId incognito contactId - _ -> processChatCommand $ APIConnect userId incognito (Just cReqUri) + connectContactViaAddress user incognito ct ccLink + ConnectSimplex incognito -> withUser $ \user -> do + plan <- contactRequestPlan user adminContactReq `catchChatError` const (pure $ CPContactAddress CAPOk) + connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId - APICreateMyAddress userId -> withUserId userId $ \user -> procCmd $ do + APICreateMyAddress userId short -> withUserId userId $ \user -> procCmd $ do subMode <- chatReadVar subscriptionMode - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing IKPQOn subMode - withFastStore $ \db -> createUserContactLink db user connId cReq subMode - pure $ CRUserContactLinkCreated user cReq - CreateMyAddress -> withUser $ \User {userId} -> - processChatCommand $ APICreateMyAddress userId + let userData = shortLinkUserData short + (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact userData Nothing IKPQOn subMode + ccLink' <- shortenCreatedLink ccLink + withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode + pure $ CRUserContactLinkCreated user ccLink' + CreateMyAddress short -> withUser $ \User {userId} -> + processChatCommand $ APICreateMyAddress userId short APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do conns <- withFastStore $ \db -> getUserAddressConnections db vr user withChatLock "deleteMyAddress" $ do @@ -1819,8 +1821,9 @@ processChatCommand' vr = \case let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing APISetProfileAddress userId True -> withUserId userId $ \user@User {profile = p} -> do - ucl@UserContactLink {connReqContact} <- withFastStore (`getUserAddress` user) - let p' = (fromLocalProfile p :: Profile) {contactLink = Just connReqContact} + ucl@UserContactLink {connLinkContact = CCLink cReq _} <- withFastStore (`getUserAddress` user) + -- TODO [short links] replace with short links + let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ CLFull cReq} updateProfile_ user p' $ withFastStore' $ \db -> setUserProfileContactLink db user $ Just ucl SetProfileAddress onOff -> withUser $ \User {userId} -> processChatCommand $ APISetProfileAddress userId onOff @@ -1998,7 +2001,7 @@ processChatCommand' vr = \case Nothing -> do gVar <- asks random subMode <- chatReadVar subscriptionMode - (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode member <- withFastStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -2342,16 +2345,18 @@ processChatCommand' vr = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db vr user gName) - APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do + APICreateGroupLink groupId mRole short -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) IKPQOff subMode - withFastStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode - pure $ CRGroupLinkCreated user gInfo cReq mRole + userData = shortLinkUserData short + (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact userData (Just crClientData) IKPQOff subMode + ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink + withFastStore $ \db -> createGroupLink db user gInfo connId ccLink' groupLinkId mRole subMode + pure $ CRGroupLinkCreated user gInfo ccLink' mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId (groupLinkId, groupLink, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo @@ -2377,7 +2382,7 @@ processChatCommand' vr = \case when (isJust $ memberContactId m) $ throwChatError $ CECommandError "member contact already exists" subMode <- chatReadVar subscriptionMode -- TODO PQ should negotitate contact connection with PQSupportOn? - (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOff subMode + (connId, CCLink cReq _) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode -- TODO not sure it is correct to set connections status here? @@ -2398,9 +2403,9 @@ processChatCommand' vr = \case toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] pure $ CRNewMemberContactSentInv user ct' g m _ -> throwChatError CEGroupMemberNotActive - CreateGroupLink gName mRole -> withUser $ \user -> do + CreateGroupLink gName mRole short -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APICreateGroupLink groupId mRole + processChatCommand $ APICreateGroupLink groupId mRole short GroupLinkMemberRole gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIGroupLinkMemberRole groupId mRole @@ -2746,8 +2751,8 @@ processChatCommand' vr = \case CTGroup -> withFastStore $ \db -> getGroupChatItemIdByText' db user cId msg CTLocal -> withFastStore $ \db -> getLocalChatItemIdByText' db user cId msg _ -> throwChatError $ CECommandError "not supported" - connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> CM ChatResponse - connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withInvitationLock "connectViaContact" (strEncode cReq) $ do + connectViaContact :: User -> IncognitoEnabled -> CreatedLinkContact -> CM ChatResponse + connectViaContact user@User {userId} incognito (CCLink cReq@(CRContactUri ConnReqUriData {crClientData}) sLnk) = withInvitationLock "connectViaContact" (strEncode cReq) $ do let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq case groupLinkId of @@ -2777,11 +2782,12 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - conn@PendingContactConnection {pccConnId} <- withFastStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup + let sLnk' = serverShortLink <$> sLnk + conn@PendingContactConnection {pccConnId} <- withFastStore' $ \db -> createConnReqConnection db userId connId cReqHash sLnk' xContactId incognitoProfile groupLinkId subMode chatV pqSup joinContact user pccConnId connId cReq incognitoProfile xContactId inGroup pqSup chatV pure $ CRSentInvitation user conn incognitoProfile - connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> CM ChatResponse - connectContactViaAddress user incognito ct cReq = + connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse + connectContactViaAddress user incognito ct (CCLink cReq shortLink) = withInvitationLock "connectContactViaAddress" (strEncode cReq) $ do newXContactId <- XContactId <$> drgRandomBytes 16 let pqSup = PQSupportOn @@ -2790,10 +2796,10 @@ processChatCommand' vr = \case -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - (pccConnId, ct') <- withFastStore $ \db -> createAddressContactConnection db vr user ct connId cReqHash newXContactId incognitoProfile subMode chatV pqSup + (pccConnId, ct') <- withFastStore $ \db -> createAddressContactConnection db vr user ct connId cReqHash shortLink newXContactId incognitoProfile subMode chatV pqSup joinContact user pccConnId connId cReq incognitoProfile newXContactId False pqSup chatV pure $ CRSentInvitationToContact user ct' incognitoProfile - prepareContact :: User -> ConnectionRequestUri 'CMContact -> PQSupport -> CM (ConnId, VersionChat) + prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) prepareContact user cReq pqSup = do -- 0) toggle disabled - PQSupportOff -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression @@ -2804,7 +2810,7 @@ processChatCommand' vr = \case let chatV = agentToChatVersion agentV connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup pure (connId, chatV) - joinContact :: User -> Int64 -> ConnId -> ConnectionRequestUri 'CMContact -> Maybe Profile -> XContactId -> Bool -> PQSupport -> VersionChat -> CM () + joinContact :: User -> Int64 -> ConnId -> ConnReqContact -> Maybe Profile -> XContactId -> Bool -> PQSupport -> VersionChat -> CM () joinContact user pccConnId connId cReq incognitoProfile xContactId inGroup pqSup chatV = do let profileToSend = userProfileToSend user incognitoProfile Nothing inGroup dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend $ Just xContactId) @@ -3110,32 +3116,77 @@ processChatCommand' vr = \case pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" processChatCommand $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings - connectPlan :: User -> AConnectionRequestUri -> CM ConnectionPlan - connectPlan user (ACR SCMInvitation (CRInvitationUri crData e2e)) = do - withFastStore' (\db -> getConnectionEntityByConnReq db vr user cReqSchemas) >>= \case - Nothing -> pure $ CPInvitationLink ILPOk - Just (RcvDirectMsgConnection Connection {connStatus = ConnPrepared} Nothing) -> - pure $ CPInvitationLink ILPOk - Just (RcvDirectMsgConnection conn ct_) -> do - let Connection {connStatus, contactConnInitiated} = conn - if - | connStatus == ConnNew && contactConnInitiated -> - pure $ CPInvitationLink ILPOwnLink - | not (connReady conn) -> - pure $ CPInvitationLink (ILPConnecting ct_) - | otherwise -> case ct_ of - Just ct -> pure $ CPInvitationLink (ILPKnown ct) - Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" - Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + connectPlan :: User -> AConnectionLink -> CM (ACreatedConnLink, ConnectionPlan) + connectPlan user (ACL SCMInvitation cLink) = case cLink of + CLFull cReq -> invitationReqAndPlan cReq Nothing + CLShort l -> do + let l' = serverShortLink l + withFastStore' (\db -> getConnectionEntityViaShortLink db vr user l') >>= \case + Just (cReq, ent) -> + (ACCL SCMInvitation (CCLink cReq (Just l')),) <$> (invitationEntityPlan ent `catchChatError` (pure . CPError)) + Nothing -> getShortLinkConnReq user l' >>= (`invitationReqAndPlan` Just l') where - cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) - cReqSchemas = + invitationReqAndPlan cReq sLnk_ = do + plan <- inviationRequestPlan user cReq `catchChatError` (pure . CPError) + pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) + connectPlan user (ACL SCMContact cLink) = case cLink of + CLFull cReq -> contactReqAndPlan cReq Nothing + CLShort l@(CSLContact _ ct _ _) -> do + let l' = serverShortLink l + case ct of + CCTContact -> + withFastStore' (\db -> getUserContactLinkViaShortLink db user l') >>= \case + Just (UserContactLink (CCLink cReq _) _) -> pure (ACCL SCMContact $ CCLink cReq (Just l'), CPContactAddress CAPOwnLink) + Nothing -> getShortLinkConnReq user l' >>= (`contactReqAndPlan` Just l') + CCTGroup -> + withFastStore' (\db -> getGroupInfoViaUserShortLink db vr user l') >>= \case + Just (cReq, g) -> pure (ACCL SCMContact $ CCLink cReq (Just l'), CPGroupLink (GLPOwnLink g)) + Nothing -> getShortLinkConnReq user l' >>= (`contactReqAndPlan` Just l') + CCTChannel -> throwChatError $ CECommandError "channel links are not supported in this version" + where + contactReqAndPlan cReq sLnk_ = do + plan <- contactRequestPlan user cReq `catchChatError` (pure . CPError) + pure (ACCL SCMContact $ CCLink cReq sLnk_, plan) + connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse + connectWithPlan user@User {userId} incognito ccLink plan + | connectionPlanProceed plan = do + case plan of CPError e -> toView $ CRChatError (Just user) e; _ -> pure () + case plan of + CPContactAddress (CAPContactViaAddress Contact {contactId}) -> + processChatCommand $ APIConnectContactViaAddress userId incognito contactId + _ -> processChatCommand $ APIConnect userId incognito (Just ccLink) + | otherwise = pure $ CRConnectionPlan user ccLink plan + inviationRequestPlan :: User -> ConnReqInvitation -> CM ConnectionPlan + inviationRequestPlan user cReq = do + withFastStore' (\db -> getConnectionEntityByConnReq db vr user $ cReqSchemas cReq) >>= \case + Nothing -> pure $ CPInvitationLink ILPOk + Just ent -> invitationEntityPlan ent + where + cReqSchemas :: ConnReqInvitation -> (ConnReqInvitation, ConnReqInvitation) + cReqSchemas (CRInvitationUri crData e2e) = ( CRInvitationUri crData {crScheme = SSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) - connectPlan user (ACR SCMContact (CRContactUri crData)) = do + invitationEntityPlan :: ConnectionEntity -> CM ConnectionPlan + invitationEntityPlan = \case + RcvDirectMsgConnection Connection {connStatus = ConnPrepared} Nothing -> + pure $ CPInvitationLink ILPOk + RcvDirectMsgConnection conn ct_ -> do + let Connection {connStatus, contactConnInitiated} = conn + if + | connStatus == ConnNew && contactConnInitiated -> + pure $ CPInvitationLink ILPOwnLink + | not (connReady conn) -> + pure $ CPInvitationLink (ILPConnecting ct_) + | otherwise -> case ct_ of + Just ct -> pure $ CPInvitationLink (ILPKnown ct) + Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" + _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + contactRequestPlan :: User -> ConnReqContact -> CM ConnectionPlan + contactRequestPlan user (CRContactUri crData) = do let ConnReqUriData {crClientData} = crData groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHashes = bimap hash hash cReqSchemas case groupLinkId of -- contact address Nothing -> @@ -3181,9 +3232,31 @@ processChatCommand' vr = \case ( CRContactUri crData {crScheme = SSSimplex}, CRContactUri crData {crScheme = simplexChat} ) - cReqHashes :: (ConnReqUriHash, ConnReqUriHash) - cReqHashes = bimap hash hash cReqSchemas + hash :: ConnReqContact -> ConnReqUriHash hash = ConnReqUriHash . C.sha256Hash . strEncode + getShortLinkConnReq :: User -> ConnShortLink m -> CM (ConnectionRequestUri m) + getShortLinkConnReq User {userId} l = do + l' <- restoreShortLink' l + (cReq, cData) <- withAgent (\a -> getConnShortLink a userId l') + case cData of + ContactLinkData {direct} | not direct -> throwChatError CEUnsupportedConnReq + _ -> pure () + pure cReq + -- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser, + -- and short links are stored with server hostname schema, so they wouldn't match without it. + serverShortLink :: ConnShortLink m -> ConnShortLink m + serverShortLink = \case + CSLInvitation _ srv lnkId linkKey -> CSLInvitation SLSServer srv lnkId linkKey + CSLContact _ ct srv linkKey -> CSLContact SLSServer ct srv linkKey + restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) + shortLinkUserData short = if short then Just "" else Nothing + shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) + shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM (\l -> (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config)) sLnk + createdGroupLink :: CreatedLinkContact -> CreatedLinkContact + createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toGroupLink <$> shortLink) + where + toGroupLink :: ShortLinkContact -> ShortLinkContact + toGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId @@ -3649,7 +3722,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do viaUserContactLink, groupLinkId, customUserProfileId, - connReqInv = Nothing, + connLinkInv = Nothing, localAlias, createdAt, updatedAt = createdAt @@ -4120,11 +4193,11 @@ chatCommandP = "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayNameP), - "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), + "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember) <*> shortOnOffP), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember)), + "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember) <*> shortP), "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), "/delete link #" *> (DeleteGroupLink <$> displayNameP), "/show link #" *> (ShowGroupLink <$> displayNameP), @@ -4135,12 +4208,12 @@ chatCommandP = "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), - "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), - "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), + "/_connect " *> (APIAddContact <$> A.decimal <*> shortOnOffP <*> incognitoOnOffP), + "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> connLinkP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), + ("/connect" <|> "/c") *> (AddContact <$> shortP <*> incognitoP), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), - ("/connect" <|> "/c") *> (AddContact <$> incognitoP), ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, @@ -4174,8 +4247,8 @@ chatCommandP = ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/_connect contact " *> (APIConnectContactViaAddress <$> A.decimal <*> incognitoOnOffP <* A.space <*> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), - "/_address " *> (APICreateMyAddress <$> A.decimal), - ("/address" <|> "/ad") $> CreateMyAddress, + "/_address " *> (APICreateMyAddress <$> A.decimal <*> shortOnOffP), + ("/address" <|> "/ad") *> (CreateMyAddress <$> shortP), "/_delete_address " *> (APIDeleteMyAddress <$> A.decimal), ("/delete_address" <|> "/da") $> DeleteMyAddress, "/_show_address " *> (APIShowMyAddress <$> A.decimal), @@ -4246,7 +4319,12 @@ chatCommandP = ] where choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput) + connLinkP = do + ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) + >>= mapM (\(ACR m cReq) -> ACCL m . CCLink cReq <$> optional (A.space *> strP)) + shortP = (A.space *> ("short" <|> "s")) $> True <|> pure False incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False + shortOnOffP = (A.space *> "short=" *> onOffP) <|> pure False incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index e0a9f7d040..897c36fd6a 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2375,7 +2375,7 @@ simplexTeamContactProfile = { displayName = "SimpleX Chat team", fullName = "", image = Just (ImageData "data:image/jpg;base64,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"), - contactLink = Just adminContactReq, + contactLink = Just $ CLFull adminContactReq, preferences = Nothing } @@ -2385,7 +2385,7 @@ simplexStatusContactProfile = { displayName = "SimpleX-Status", fullName = "", image = Just (ImageData "data:image/jpg;base64,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"), - contactLink = Just (either error id $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), + contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), preferences = Nothing } diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index aff023380b..bdd481d48b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1205,8 +1205,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {connReqContact, autoAccept}, gLinkInfo_) = ucl - isSimplexTeam = sameConnReqContact connReqContact adminContactReq + let (UserContactLink {connLinkContact = CCLink connReq _, autoAccept}, gLinkInfo_) = ucl + isSimplexTeam = sameConnReqContact connReq adminContactReq v = maxVersion chatVRange case autoAccept of Just AutoAccept {acceptIncognito, businessAddress} diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 0d1432c7e5..e5de9c408c 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -29,11 +29,10 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (..), ConnShortLink (..), ConnectionLink (..), ConnectionRequestUri (..), ContactConnType (..), SMPQueue (..), simplexConnReqUri, simplexShortLink) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) -import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -49,7 +48,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text} | Mention {memberName :: Text} | Email | Phone @@ -62,7 +61,7 @@ mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) Mention name -> Just name _ -> Nothing -data SimplexLinkType = XLContact | XLInvitation | XLGroup +data SimplexLinkType = XLContact | XLInvitation | XLGroup | XLChannel deriving (Eq, Show) colored :: Color -> Format @@ -248,24 +247,34 @@ markdownP = mconcat <$> A.many' fragmentP ')' -> False c -> isPunctuation c uriMarkdown s = case strDecode $ encodeUtf8 s of - Right cReq -> markdown (simplexUriFormat cReq) s + Right cLink -> markdown (simplexUriFormat cLink) s _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked - simplexUriFormat :: AConnectionRequestUri -> Format + simplexUriFormat :: AConnectionLink -> Format simplexUriFormat = \case - ACR _ (CRContactUri crData) -> - let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = SSSimplex} - in SimplexLink (linkType' crData) uri $ uriHosts crData - ACR _ (CRInvitationUri crData e2e) -> - let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = SSSimplex} e2e - in SimplexLink XLInvitation uri $ uriHosts crData - where - uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues - linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of - Just (CRDataGroup _) -> XLGroup - Nothing -> XLContact + ACL m (CLFull cReq) -> case cReq of + CRContactUri crData -> SimplexLink (linkType' crData) cLink $ uriHosts crData + CRInvitationUri crData _ -> SimplexLink XLInvitation cLink $ uriHosts crData + where + cLink = ACL m $ CLFull $ simplexConnReqUri cReq + uriHosts ConnReqUriData {crSmpQueues} = L.map strEncodeText $ sconcat $ L.map (host . qServer) crSmpQueues + linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of + Just (CRDataGroup _) -> XLGroup + Nothing -> XLContact + ACL m (CLShort sLnk) -> case sLnk of + CSLContact _ ct srv _ -> SimplexLink (linkType' ct) cLink $ uriHosts srv + CSLInvitation _ srv _ _ -> SimplexLink XLInvitation cLink $ uriHosts srv + where + cLink = ACL m $ CLShort $ simplexShortLink sLnk + uriHosts srv = L.map strEncodeText $ host srv + linkType' = \case + CCTGroup -> XLGroup + CCTChannel -> XLChannel + CCTContact -> XLContact + strEncodeText :: StrEncoding a => a -> Text + strEncodeText = safeDecodeUtf8 . strEncode markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 5240460c9c..8c4490a2c4 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -275,6 +275,10 @@ data UserServer' s (p :: ProtocolType) = UserServer } deriving (Show) +presetServerAddress :: UserServer' s p -> ProtocolServer p +presetServerAddress UserServer {server = ProtoServerWithAuth srv _} = srv +{-# INLINE presetServerAddress #-} + data PresetOperator = PresetOperator { operator :: Maybe NewServerOperator, smp :: [NewUserServer 'PSMP], @@ -297,6 +301,9 @@ operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of SPSMP -> useSMP SPXFTP -> useXFTP +presetServer' :: Bool -> ProtocolServer p -> NewUserServer p +presetServer' enabled = presetServer enabled . (`ProtoServerWithAuth` Nothing) + presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p presetServer = newUserServer_ True diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs new file mode 100644 index 0000000000..4aa0903d3c --- /dev/null +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -0,0 +1,117 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Operators.Presets where + +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Simplex.Chat.Operators +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Protocol (ProtocolType (..), SMPServer) + +operatorSimpleXChat :: NewServerOperator +operatorSimpleXChat = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTSimplex, + tradeName = "SimpleX Chat", + legalName = Just "SimpleX Chat Ltd", + serverDomains = ["simplex.im"], + conditionsAcceptance = CARequired Nothing, + enabled = True, + smpRoles = allRoles, + xftpRoles = allRoles + } + +operatorFlux :: NewServerOperator +operatorFlux = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTFlux, + tradeName = "Flux", + legalName = Just "InFlux Technologies Limited", + serverDomains = ["simplexonflux.com"], + conditionsAcceptance = CARequired Nothing, + enabled = False, + smpRoles = ServerRoles {storage = False, proxy = True}, + xftpRoles = ServerRoles {storage = False, proxy = True} + } + +-- Please note: if any servers are removed from the lists below, they MUST be added here. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +allPresetServers :: NonEmpty SMPServer +allPresetServers = enabledSimplexChatSMPServers <> disabledSimplexChatSMPServers <> fluxSMPServers_ + -- TODO [short links] remove, added for testing + <> ["smp://8Af90NX2TTkKEJAF1RCg69P_Odg2Z-6_J6DOKUqK3rQ=@smp7.simplex.im,dbxqutskmmbkbrs7ofi7pmopeyhgi5cxbjbh4ummgmep4r6bz4cbrcid.onion"] + +simplexChatSMPServers :: [NewUserServer 'PSMP] +simplexChatSMPServers = + map (presetServer' True) (L.toList enabledSimplexChatSMPServers) + <> map (presetServer' False) (L.toList disabledSimplexChatSMPServers) + +-- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +enabledSimplexChatSMPServers :: NonEmpty SMPServer +enabledSimplexChatSMPServers = + [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", + "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", + "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", + "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", + "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", + "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", + "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" + ] + +-- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +disabledSimplexChatSMPServers :: NonEmpty SMPServer +disabledSimplexChatSMPServers = + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" + ] + +fluxSMPServers :: [NewUserServer 'PSMP] +fluxSMPServers = map (presetServer' True) $ L.toList fluxSMPServers_ + +-- Please note: if any servers are removed from this list, they MUST be added to allPresetServers. +-- Otherwise previously created short links won't work. +-- +-- !!! Also, if any servers need to be added, shortLinkPresetServers will need to be be split to two, +-- so that option used for restoring links is updated earlier, for backward/forward compatibility. +fluxSMPServers_ :: NonEmpty SMPServer +fluxSMPServers_ = + [ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion", + "smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion", + "smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion", + "smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion", + "smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion", + "smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion" + ] + +fluxXFTPServers :: [NewUserServer 'PXFTP] +fluxXFTPServers = + map + (presetServer True) + [ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion", + "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion", + "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion", + "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion", + "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", + "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" + ] diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index b174ecd02e..f7c429e93e 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -7,11 +7,14 @@ module Simplex.Chat.Options.Postgres where import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative +import Numeric.Natural (Natural) import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) data ChatDbOpts = ChatDbOpts { dbConnstr :: String, - dbSchemaPrefix :: String + dbSchemaPrefix :: String, + dbPoolSize :: Natural, + dbCreateSchema :: Bool } chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts @@ -33,16 +36,32 @@ chatDbOptsP _appDir defaultDbName = do <> value "simplex_v1" <> showDefault ) - pure ChatDbOpts {dbConnstr, dbSchemaPrefix} + dbPoolSize <- + option + auto + ( long "pool-size" + <> metavar "DB_POOL_SIZE" + <> help "Database connection pool size" + <> value 1 + <> showDefault + ) + dbCreateSchema <- + switch + ( long "create-schema" + <> help "Create database schema when it does not exist" + ) + pure ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix} dbSuffix _keepKey = +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey = DBOpts { connstr = B.pack dbConnstr, - schema = if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix + schema = B.pack $ if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix, + poolSize = dbPoolSize, + createSchema = dbCreateSchema } chatSuffix :: String @@ -58,11 +77,13 @@ mobileDbOpts schemaPrefix connstr = do pure $ ChatDbOpts { dbConnstr, - dbSchemaPrefix + dbSchemaPrefix, + dbPoolSize = 1, + dbCreateSchema = True } removeDbKey :: ChatDbOpts -> ChatDbOpts removeDbKey = id errorDbStr :: DBOpts -> String -errorDbStr DBOpts {schema} = schema +errorDbStr DBOpts {schema} = B.unpack schema diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 617fef1759..8407cb11a7 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -12,6 +13,7 @@ module Simplex.Chat.Store.Connections ( getChatLockEntity, getConnectionEntity, getConnectionEntityByConnReq, + getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, getConnectionsToSubscribe, unsetConnectionToSubscribe, @@ -33,7 +35,7 @@ import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (ConnId) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnShortLink, ConnectionMode (..)) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -206,6 +208,26 @@ getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ +getConnectionEntityViaShortLink :: DB.Connection -> VersionRangeChat -> User -> ConnShortLink 'CMInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity)) +getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do + (cReq, connId) <- ExceptT getConnReqConnId + (cReq,) <$> getConnectionEntity db vr user connId + where + getConnReqConnId = + firstRow' toConnReqConnId (SEInternalError "connection not found") $ + DB.query + db + [sql| + SELECT conn_req_inv, agent_conn_id + FROM connections + WHERE user_id = ? AND short_link_inv = ? LIMIT 1 + |] + (userId, shortLink) + -- cReq is Maybe - it is removed when connection is established + toConnReqConnId = \case + (Just cReq, connId) -> Right (cReq, connId) + _ -> Left $ SEInternalError "no connection request" + -- search connection for connection plan: -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index d473c81758..4de832a8b1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -100,7 +100,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), InvitationId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -122,7 +122,7 @@ getPendingContactConnection db userId connId = do DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND connection_id = ? @@ -148,14 +148,14 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createAddressContactConnection :: DB.Connection -> VersionRangeChat -> User -> Contact -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO (Int64, Contact) -createAddressContactConnection db vr user@User {userId} Contact {contactId} acId cReqHash xContactId incognitoProfile subMode chatV pqSup = do - PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile Nothing subMode chatV pqSup +createAddressContactConnection :: DB.Connection -> VersionRangeChat -> User -> Contact -> ConnId -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> ExceptT StoreError IO (Int64, Contact) +createAddressContactConnection db vr user@User {userId} Contact {contactId} acId cReqHash sLnk xContactId incognitoProfile subMode chatV pqSup = do + PendingContactConnection {pccConnId} <- liftIO $ createConnReqConnection db userId acId cReqHash sLnk xContactId incognitoProfile Nothing subMode chatV pqSup liftIO $ DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, pccConnId) (pccConnId,) <$> getContact db vr user contactId -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId subMode chatV pqSup = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash sLnk xContactId incognitoProfile groupLinkId subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -164,16 +164,16 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + via_contact_uri_hash, via_short_link_contact, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, pccConnStatus, ConnContact, BI True, cReqHash, xContactId) + ( (userId, acId, pccConnStatus, ConnContact, BI True, cReqHash, sLnk, xContactId) :. (customUserProfileId, BI (isJust groupLinkId), groupLinkId) :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connLinkInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} getConnReqContactXContactId :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db vr user@User {userId} cReqHash = do @@ -214,8 +214,8 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = do (userId, cReqHash, CSActive) mapM (addDirectChatTags db) ct_ -createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection -createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do +createDirectConnection :: DB.Connection -> User -> ConnId -> CreatedLinkInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection +createDirectConnection db User {userId} acId ccLink@(CCLink cReq shortLinkInv) pccConnStatus incognitoProfile subMode chatV pqSup = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let contactConnInitiated = pccConnStatus == ConnNew @@ -223,15 +223,15 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + (user_id, agent_conn_id, conn_req_inv, short_link_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, pccConnStatus, ConnContact, BI contactConnInitiated, customUserProfileId) + ( (userId, acId, cReq, shortLinkInv, pccConnStatus, ConnContact, BI contactConnInitiated, customUserProfileId) :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connLinkInv = Just ccLink, localAlias = "", createdAt, updatedAt = createdAt} createIncognitoProfile :: DB.Connection -> User -> Profile -> IO Int64 createIncognitoProfile db User {userId} p = do @@ -904,7 +904,7 @@ getPendingContactConnections db User {userId} = do <$> DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -989,7 +989,7 @@ updateConnectionStatus_ :: DB.Connection -> Int64 -> ConnStatus -> IO () updateConnectionStatus_ db connId connStatus = do currentTs <- getCurrentTime if connStatus == ConnReady - then DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) + then DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL, short_link_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) else DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e2045165d7..5db180aec9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -39,6 +39,7 @@ module Simplex.Chat.Store.Groups getGroup, getGroupInfo, getGroupInfoByUserContactLinkConnReq, + getGroupInfoViaUserShortLink, getGroupInfoByGroupLinkHash, updateGroupProfile, updateGroupPreferences, @@ -158,14 +159,14 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, firstRow', ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) @@ -176,21 +177,21 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions)) = Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions)) toMaybeGroupMember _ _ = Nothing -createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () -createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId memberRole subMode = +createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () +createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, BI True, currentTs, currentTs) + "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" + (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, shortLink, memberRole, BI True, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff @@ -251,12 +252,12 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, ConnReqContact, GroupMemberRole) +getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, CreatedLinkContact, GroupMemberRole) getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow groupLink (SEGroupLinkNotFound gInfo) $ - DB.query db "SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) + DB.query db "SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) where - groupLink (linkId, cReq, mRole_) = (linkId, cReq, fromMaybe GRMember mRole_) + groupLink (linkId, cReq, shortLink, mRole_) = (linkId, CCLink cReq shortLink, fromMaybe GRMember mRole_) getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) getGroupLinkId db User {userId} GroupInfo {groupId} = @@ -1701,8 +1702,9 @@ getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do + -- fmap join is to support group_id = NULL if non-group contact request is sent to this function (e.g., if client data is appended). groupId_ <- - maybeFirstRow fromOnly $ + fmap join . maybeFirstRow fromOnly $ DB.query db [sql| @@ -1713,6 +1715,26 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ +getGroupInfoViaUserShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) +getGroupInfoViaUserShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do + (cReq, groupId) <- ExceptT getConnReqGroup + (cReq,) <$> getGroupInfo db vr user groupId + where + getConnReqGroup = + firstRow' toConnReqGroupId (SEInternalError "group link not found") $ + DB.query + db + [sql| + SELECT conn_req_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? + |] + (userId, shortLink) + toConnReqGroupId = \case + -- cReq is "not null", group_id is nullable + (cReq, Just groupId) -> Right (cReq, groupId) + _ -> Left $ SEInternalError "no conn req or group ID" + getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 2ef501e931..54d40794b0 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -166,7 +166,7 @@ import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Shared -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, ConnShortLink, ConnectionMode (..), MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -1015,7 +1015,7 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of [sql| SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -1031,7 +1031,7 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of PTLast count -> DB.query db (query <> " ORDER BY updated_at DESC LIMIT ?") (params search :. Only count) PTAfter ts count -> DB.query db (query <> " AND updated_at > ? ORDER BY updated_at ASC LIMIT ?") (params search :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND updated_at < ? ORDER BY updated_at DESC LIMIT ?") (params search :. (ts, count)) - toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChatPreviewData + toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, Maybe (ConnShortLink 'CMInvitation), LocalAlias, UTCTime, UTCTime) -> AChatPreviewData toPreview connRow = let conn@PendingContactConnection {updatedAt} = toPendingContactConnection connRow aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] emptyChatStats diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 285a952279..dc7202edc8 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -5,11 +5,13 @@ module Simplex.Chat.Store.Postgres.Migrations (migrations) where import Data.List (sortOn) import Data.Text (Text) import Simplex.Chat.Store.Postgres.Migrations.M20241220_initial +import Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = - [ ("20241220_initial", m20241220_initial, Nothing) + [ ("20241220_initial", m20241220_initial, Nothing), + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs new file mode 100644 index 0000000000..de4f699377 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250402_short_links :: Text +m20250402_short_links = + T.pack + [r| +ALTER TABLE user_contact_links ADD COLUMN short_link_contact BYTEA; +ALTER TABLE connections ADD COLUMN short_link_inv BYTEA; +|] + +down_m20250402_short_links :: Text +down_m20250402_short_links = + T.pack + [r| +ALTER TABLE user_contact_links DROP COLUMN short_link_contact; +ALTER TABLE connections DROP COLUMN short_link_inv; +|] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index bdd54c3f1e..a7dc154d9d 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -50,6 +50,7 @@ module Simplex.Chat.Store.Profiles getUserContactLinkById, getGroupLinkInfo, getUserContactLinkByConnReq, + getUserContactLinkViaShortLink, getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, @@ -100,7 +101,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) -import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, ConnectionLink (..), CreatedConnLink (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -326,11 +327,13 @@ setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profil SET contact_link = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ? |] - (connReqContact_, ts, userId, profileId) - pure (user :: User) {profile = p {contactLink = connReqContact_}} + (contactLink, ts, userId, profileId) + pure (user :: User) {profile = p {contactLink}} where - connReqContact_ = case ucl_ of - Just UserContactLink {connReqContact} -> Just connReqContact + -- TODO [short links] this should be replaced with short links once they are supported by all clients. + -- Or, maybe, we want to allow both, when both are optional. + contactLink = case ucl_ of + Just UserContactLink {connLinkContact = CCLink cReq _} -> Just $ CLFull cReq _ -> Nothing -- only used in tests @@ -346,17 +349,17 @@ getUserContactProfiles db User {userId} = |] (Only userId) where - toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) -> Profile + toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) -> Profile toContactProfile (displayName, fullName, image, contactLink, preferences) = Profile {displayName, fullName, image, contactLink, preferences} -createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> SubscriptionMode -> ExceptT StoreError IO () -createUserContactLink db User {userId} agentConnId cReq subMode = +createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" - (userId, cReq, currentTs, currentTs) + "INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, created_at, updated_at) VALUES (?,?,?,?,?)" + (userId, cReq, shortLink, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff @@ -450,7 +453,7 @@ data UserMsgReceiptSettings = UserMsgReceiptSettings deriving (Show) data UserContactLink = UserContactLink - { connReqContact :: ConnReqContact, + { connLinkContact :: CreatedLinkContact, autoAccept :: Maybe AutoAccept } deriving (Show) @@ -472,22 +475,15 @@ $(J.deriveJSON defaultJSON ''AutoAccept) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (ConnReqContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink -toUserContactLink (connReq, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = - UserContactLink connReq $ +toUserContactLink :: (ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink +toUserContactLink (connReq, shortLink, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = + UserContactLink (CCLink connReq shortLink) $ if autoAccept then Just AutoAccept {businessAddress, acceptIncognito, autoReply} else Nothing getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink getUserAddress db User {userId} = ExceptT . firstRow toUserContactLink SEUserContactLinkNotFound $ - DB.query - db - [sql| - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL - |] - (Only userId) + DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL") (Only userId) getUserContactLinkById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO (UserContactLink, Maybe GroupLinkInfo) getUserContactLinkById db userId userContactLinkId = @@ -495,7 +491,7 @@ getUserContactLinkById db userId userContactLinkId = DB.query db [sql| - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? |] @@ -521,14 +517,19 @@ getGroupLinkInfo db userId groupId = getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ - DB.query - db - [sql| - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND conn_req_contact IN (?,?) - |] - (userId, cReqSchema1, cReqSchema2) + DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND conn_req_contact IN (?,?)") (userId, cReqSchema1, cReqSchema2) + +getUserContactLinkViaShortLink :: DB.Connection -> User -> ShortLinkContact -> IO (Maybe UserContactLink) +getUserContactLinkViaShortLink db User {userId} shortLink = + maybeFirstRow toUserContactLink $ + DB.query db (userContactLinkQuery <> " WHERE user_id = ? AND short_link_contact = ?") (userId, shortLink) + +userContactLinkQuery :: Query +userContactLinkQuery = + [sql| + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + |] getContactWithoutConnViaAddress :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 5aec894ee7..e9c106528a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -128,7 +128,8 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_hist import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions import Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts import Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes -import Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope +import Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links +import Simplex.Chat.Store.SQLite.Migrations.M20250403_group_scope import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -257,7 +258,8 @@ schemaMigrations = ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions), ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts), ("20250130_indexes", m20250130_indexes, Just down_m20250130_indexes), - ("20250310_group_scope", m20250310_group_scope, Just down_m20250310_group_scope) + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links), + ("20250403_group_scope", m20250403_group_scope, Just down_m20250403_group_scope) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs new file mode 100644 index 0000000000..62637c0782 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250402_short_links.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250402_short_links where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250402_short_links :: Query +m20250402_short_links = + [sql| +ALTER TABLE user_contact_links ADD COLUMN short_link_contact BLOB; +ALTER TABLE connections ADD COLUMN short_link_inv BLOB; +ALTER TABLE connections ADD COLUMN via_short_link_contact BLOB; + +|] + +down_m20250402_short_links :: Query +down_m20250402_short_links = + [sql| +ALTER TABLE user_contact_links DROP COLUMN short_link_contact; +ALTER TABLE connections DROP COLUMN short_link_inv; +ALTER TABLE connections DROP COLUMN via_short_link_contact; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs similarity index 89% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs index 51aef80563..b203d14d2f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs @@ -1,13 +1,13 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope where +module Simplex.Chat.Store.SQLite.Migrations.M20250403_group_scope where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -- TODO [knocking] review indexes (drop idx_chat_items_groups_item_ts?) -m20250310_group_scope :: Query -m20250310_group_scope = +m20250403_group_scope :: Query +m20250403_group_scope = [sql| ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; @@ -30,8 +30,8 @@ CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( ); |] -down_m20250310_group_scope :: Query -down_m20250310_group_scope = +down_m20250403_group_scope :: Query +down_m20250403_group_scope = [sql| DROP INDEX idx_chat_items_group_scope_item_ts; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 31bc2feb81..e6a567035e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -443,6 +443,22 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + SELECT link_id, snd_private_key + FROM inv_short_links + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=?) + +Query: + SELECT link_key, snd_private_key, snd_id + FROM inv_short_links + WHERE host = ? AND port = ? AND link_id = ? + +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) + Query: SELECT s.internal_id, m.msg_type, s.internal_hash, s.rcpt_internal_id, s.rcpt_status FROM snd_messages s @@ -466,6 +482,19 @@ Query: Plan: +Query: + INSERT INTO inv_short_links + (host, port, server_key_hash, link_id, link_key, snd_private_key, snd_id) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT (host, port, link_id) + DO UPDATE SET + server_key_hash = EXCLUDED.server_key_hash, + link_key = EXCLUDED.link_key, + snd_private_key = EXCLUDED.snd_private_key, + snd_id = EXCLUDED.snd_id + +Plan: + Query: INSERT INTO messages (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) @@ -524,7 +553,10 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_ Query: INSERT INTO rcv_queues - (host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, snd_id, snd_secure, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + ( host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, + snd_id, queue_mode, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, + link_id, link_key, link_priv_sig_key, link_enc_fixed_data + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); Plan: @@ -546,14 +578,14 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_ Query: INSERT INTO snd_queues - (host, port, snd_id, snd_secure, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, + (host, port, snd_id, queue_mode, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, status, snd_queue_id, snd_primary, replace_snd_queue_id, smp_client_version, server_key_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (host, port, snd_id) DO UPDATE SET host=EXCLUDED.host, port=EXCLUDED.port, snd_id=EXCLUDED.snd_id, - snd_secure=EXCLUDED.snd_secure, + queue_mode=EXCLUDED.queue_mode, conn_id=EXCLUDED.conn_id, snd_public_key=EXCLUDED.snd_public_key, snd_private_key=EXCLUDED.snd_private_key, @@ -631,6 +663,14 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + UPDATE inv_short_links + SET snd_id = ? + WHERE host = ? AND port = ? AND link_id = ? + +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) + Query: UPDATE ratchets SET x3dh_priv_key_1 = ?, x3dh_priv_key_2 = ?, pq_priv_kem = ? @@ -691,7 +731,7 @@ SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) Query: SELECT - c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.snd_secure, + c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.queue_mode, q.snd_public_key, q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version FROM snd_queues q @@ -705,9 +745,10 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -719,9 +760,10 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -733,9 +775,10 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -747,9 +790,10 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -761,9 +805,10 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, - q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, + q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id @@ -799,6 +844,10 @@ Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id Plan: SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) +Query: DELETE FROM inv_short_links WHERE host = ? AND port = ? AND link_id = ? +Plan: +SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) + Query: DELETE FROM messages WHERE conn_id = ? AND internal_id = ?; Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index ebbf578434..0a11f668ff 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -484,6 +484,14 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT conn_req_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + Query: SELECT conn_req_contact, group_id FROM user_contact_links @@ -492,6 +500,14 @@ Query: Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT conn_req_inv, agent_conn_id + FROM connections + WHERE user_id = ? AND short_link_inv = ? LIMIT 1 + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) + Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, @@ -1394,7 +1410,7 @@ SCAN cc Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -1411,7 +1427,7 @@ SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -1428,7 +1444,7 @@ SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -2982,23 +2998,7 @@ Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND conn_req_contact IN (?,?) - -Plan: -SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) - -Query: - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content - FROM user_contact_links - WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL - -Plan: -SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) - -Query: - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? @@ -3017,7 +3017,7 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -3027,7 +3027,7 @@ Plan: SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) Query: - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND connection_id = ? @@ -3999,9 +3999,9 @@ Plan: Query: INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + (user_id, agent_conn_id, conn_req_inv, short_link_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4017,9 +4017,9 @@ Plan: Query: INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + via_contact_uri_hash, via_short_link_contact, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4713,6 +4713,27 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) +Query: + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + +Query: + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) + +Query: + SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND short_link_contact = ? +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -5482,10 +5503,10 @@ SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO temp_conn_ids (conn_id) VALUES (?) Plan: -Query: INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?) +Query: INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, created_at, updated_at) VALUES (?,?,?,?,?) Plan: -Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) +Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?) @@ -5714,7 +5735,7 @@ Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_i Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 Plan: SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) @@ -5762,7 +5783,7 @@ Query: UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_i Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ? +Query: UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL, short_link_inv = NULL WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b87729b6e8..46d14ad6a8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -302,6 +302,8 @@ CREATE TABLE connections( pq_snd_enabled INTEGER, pq_rcv_enabled INTEGER, quota_err_counter INTEGER NOT NULL DEFAULT 0, + short_link_inv BLOB, + via_short_link_contact BLOB, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -321,6 +323,7 @@ CREATE TABLE user_contact_links( group_link_id BLOB, group_link_member_role TEXT NULL, business_address INTEGER DEFAULT 0, + short_link_contact BLOB, UNIQUE(user_id, local_display_name) ); CREATE TABLE contact_requests( diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 847c74715b..d876c9bd92 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -35,7 +36,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnShortLink, ConnectionMode (..), CreatedConnLink (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -416,7 +417,7 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] (userId, profileId, userId, profileId, userId, profileId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' @@ -441,10 +442,10 @@ getProfileById db userId profileId = |] (userId, profileId) where - toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) -> LocalProfile + toProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) -> LocalProfile toProfile (displayName, fullName, image, contactLink, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact) :. (Maybe XContactId, PQSupport, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact) :. (Maybe XContactId, PQSupport, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, contactId_, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, contactLink) :. (xContactId, pqSupport, preferences, createdAt, updatedAt, minVer, maxVer)) = do @@ -462,7 +463,7 @@ userQuery = JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} where @@ -470,9 +471,10 @@ toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, dis fullPreferences = mergePreferences Nothing userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ -toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> PendingContactConnection -toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, localAlias, createdAt, updatedAt) = - PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, localAlias, createdAt, updatedAt} +toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, Maybe (ConnShortLink 'CMInvitation), LocalAlias, UTCTime, UTCTime) -> PendingContactConnection +toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, shortLinkInv, localAlias, createdAt, updatedAt) = + let connLinkInv = (`CCLink` shortLinkInv) <$> connReqInv + in PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connLinkInv, localAlias, createdAt, updatedAt} getConnReqInv :: DB.Connection -> Int64 -> ExceptT StoreError IO ConnReqInvitation getConnReqInv db connId = @@ -579,7 +581,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow -type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64) +type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 958cd9d75f..e432343839 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -9,12 +9,13 @@ module Simplex.Chat.Terminal where import Control.Monad import qualified Data.List.NonEmpty as L -import Simplex.Chat (defaultChatConfig, operatorSimpleXChat) +import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators +import Simplex.Chat.Operators.Presets (operatorSimpleXChat) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7b21187559..49660b99ba 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -51,7 +51,7 @@ import Simplex.Chat.Types.UITheme import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) -import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink, ConnectionLink, ConnectionMode (..), ConnectionRequestUri, CreatedConnLink, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) @@ -220,6 +220,8 @@ contactConnId c = aConnId <$> contactConn c type IncognitoEnabled = Bool +type CreateShortLink = Bool + contactConnIncognito :: Contact -> IncognitoEnabled contactConnIncognito = maybe False connIncognito . contactConn @@ -559,7 +561,7 @@ data Profile = Profile { displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - contactLink :: Maybe ConnReqContact, + contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id @@ -592,7 +594,7 @@ data LocalProfile = LocalProfile displayName :: ContactName, fullName :: Text, image :: Maybe ImageData, - contactLink :: Maybe ConnReqContact, + contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, localAlias :: LocalAlias } @@ -1449,6 +1451,14 @@ type ConnReqInvitation = ConnectionRequestUri 'CMInvitation type ConnReqContact = ConnectionRequestUri 'CMContact +type CreatedLinkInvitation = CreatedConnLink 'CMInvitation + +type CreatedLinkContact = CreatedConnLink 'CMContact + +type ConnLinkContact = ConnectionLink 'CMContact + +type ShortLinkContact = ConnShortLink 'CMContact + data Connection = Connection { connId :: Int64, agentConnId :: AgentConnId, @@ -1526,7 +1536,7 @@ data PendingContactConnection = PendingContactConnection viaUserContactLink :: Maybe Int64, groupLinkId :: Maybe GroupLinkId, customUserProfileId :: Maybe Int64, - connReqInv :: Maybe ConnReqInvitation, + connLinkInv :: Maybe CreatedLinkInvitation, localAlias :: Text, createdAt :: UTCTime, updatedAt :: UTCTime diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 32dd81ee8b..4864e273c3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -90,7 +90,7 @@ serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> Time serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] -responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case +responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] @@ -108,7 +108,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserServersValidation {} -> [] CRUsageConditions current _ accepted_ -> viewUsageConditions current accepted_ CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl - CRNetworkConfig cfg -> viewNetworkConfig cfg + CRNetworkConfig netCfg -> viewNetworkConfig netCfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats @@ -181,7 +181,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe HSDatabase -> databaseHelpInfo CRWelcome user -> chatWelcome user CRContactsList u cs -> ttyUser u $ viewContactsList cs - CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept + CRUserContactLink u UserContactLink {connLinkContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connLinkContact <> autoAcceptStatus_ autoAccept CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView @@ -202,10 +202,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info - CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq + CRInvitation u ccLink _ -> ttyUser u $ viewConnReqInvitation ccLink CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c CRConnectionUserChanged u c c' nu -> ttyUser u $ viewConnectionUserChanged u c nu c' - CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan + CRConnectionPlan u _ connectionPlan -> ttyUser u $ viewConnectionPlan cfg connectionPlan CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView @@ -217,7 +217,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] CRContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] CRBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] - CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkCreated u ccLink -> ttyUser u $ connReqContact_ "Your new chat address is created!" ccLink CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] @@ -318,8 +318,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g - CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole - CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole + CRGroupLinkCreated u g ccLink mRole -> ttyUser u $ groupLink_ "Group link is created!" g ccLink mRole + CRGroupLink u g ccLink mRole -> ttyUser u $ groupLink_ "Group link:" g ccLink mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] @@ -916,14 +916,17 @@ viewInvalidConnReq = plain updateStr ] -viewConnReqInvitation :: ConnReqInvitation -> [StyledString] -viewConnReqInvitation cReq = +viewConnReqInvitation :: CreatedLinkInvitation -> [StyledString] +viewConnReqInvitation (CCLink cReq shortLink) = [ "pass this invitation link to your contact (via another channel): ", "", - (plain . strEncode) (simplexChatInvitation cReq), + plain $ maybe cReqStr strEncode shortLink, "", "and ask them to connect: " <> highlight' "/c " ] + <> ["The invitation link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatInvitation cReq simplexChatInvitation :: ConnReqInvitation -> ConnReqInvitation simplexChatInvitation (CRInvitationUri crData e2e) = CRInvitationUri crData {crScheme = simplexChat} e2e @@ -978,21 +981,29 @@ viewForwardPlan count itemIds = maybe [forwardCount] $ \fc -> [confirmation fc, | otherwise = plain $ show len <> " message(s) out of " <> show count <> " can be forwarded" len = length itemIds -connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] -connReqContact_ intro cReq = +connReqContact_ :: StyledString -> CreatedLinkContact -> [StyledString] +connReqContact_ intro (CCLink cReq shortLink) = [ intro, "", - (plain . strEncode) (simplexChatContact cReq), + plain $ maybe cReqStr strEncode shortLink, "", "Anybody can send you contact requests with: " <> highlight' "/c ", "to show it again: " <> highlight' "/sa", "to share with your contacts: " <> highlight' "/profile_address on", "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] + <> ["The contact link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatContact cReq simplexChatContact :: ConnReqContact -> ConnReqContact simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simplexChat} +simplexChatContact' :: ConnLinkContact -> ConnLinkContact +simplexChatContact' = \case + CLFull (CRContactUri crData) -> CLFull $ CRContactUri crData {crScheme = simplexChat} + l@(CLShort _) -> l + autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> @@ -1005,16 +1016,19 @@ autoAcceptStatus_ = \case | otherwise = "" _ -> ["auto_accept off"] -groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString] -groupLink_ intro g cReq mRole = +groupLink_ :: StyledString -> GroupInfo -> CreatedLinkContact -> GroupMemberRole -> [StyledString] +groupLink_ intro g (CCLink cReq shortLink) mRole = [ intro, "", - (plain . strEncode) (simplexChatContact cReq), + plain $ maybe cReqStr strEncode shortLink, "", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", "to show it again: " <> highlight ("/show link #" <> viewGroupName g), "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)" ] + <> ["The group link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatContact cReq viewGroupLinkDeleted :: GroupInfo -> [StyledString] viewGroupLinkDeleted g = @@ -1450,7 +1464,7 @@ viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledS viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> maybe [] viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> maybe ["you've shared main profile with this contact"] (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) @@ -1482,7 +1496,7 @@ viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProf "member ID: " <> sShow groupMemberId ] <> maybe ["member not connected"] viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (memberSecurityCode m) | isJust stats] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn @@ -1711,21 +1725,24 @@ viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserPr | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] viewConnectionUserChanged :: User -> PendingContactConnection -> User -> PendingContactConnection -> [StyledString] -viewConnectionUserChanged User {localDisplayName = n} PendingContactConnection {pccConnId, connReqInv} User {localDisplayName = n'} PendingContactConnection {connReqInv = connReqInv'} = - case (connReqInv, connReqInv') of - (Just cReqInv, Just cReqInv') - | cReqInv /= cReqInv' -> [userChangedStr <> ", new link:"] <> newLink cReqInv' +viewConnectionUserChanged User {localDisplayName = n} PendingContactConnection {pccConnId, connLinkInv} User {localDisplayName = n'} PendingContactConnection {connLinkInv = connLinkInv'} = + case (connLinkInv, connLinkInv') of + (Just ccLink, Just ccLink') + | ccLink /= ccLink' -> [userChangedStr <> ", new link:"] <> newLink ccLink' _ -> [userChangedStr] where userChangedStr = "connection " <> sShow pccConnId <> " changed from user " <> plain n <> " to user " <> plain n' - newLink cReqInv = + newLink (CCLink cReq shortLink) = [ "", - (plain . strEncode) (simplexChatInvitation cReqInv), + plain $ maybe cReqStr strEncode shortLink, "" ] + <> ["The invitation link for old clients: " <> plain cReqStr | isJust shortLink] + where + cReqStr = strEncode $ simplexChatInvitation cReq -viewConnectionPlan :: ConnectionPlan -> [StyledString] -viewConnectionPlan = \case +viewConnectionPlan :: ChatConfig -> ConnectionPlan -> [StyledString] +viewConnectionPlan ChatConfig {logLevel, testView} = \case CPInvitationLink ilp -> case ilp of ILPOk -> [invLink "ok to connect"] ILPOwnLink -> [invLink "own link"] @@ -1764,6 +1781,7 @@ viewConnectionPlan = \case grpOrBiz GroupInfo {businessChat} = case businessChat of Just _ -> "business" Nothing -> "group" + CPError e -> viewChatError False logLevel testView e viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated @@ -2186,8 +2204,8 @@ viewChatError isCmd logLevel testView = \case CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] - CEConnectionPlan connectionPlan -> viewConnectionPlan connectionPlan CEInvalidConnReq -> viewInvalidConnReq + CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr] CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> [ plain $ ("chat message error: " <> e <> " (" <> T.unpack (T.take 120 msg) <> ")") diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 9675959bb1..9532b26a54 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -34,6 +34,7 @@ directoryServiceTests = do it "should register group" testDirectoryService it "should suspend and resume group, send message to owner" testSuspendResume it "should delete group registration" testDeleteGroup + it "admin should delete group registration" testDeleteGroupAdmin it "should change initial member role" testSetRole it "should join found group via link" testJoinGroup it "should support group names with spaces" testGroupNameWithSpaces @@ -52,10 +53,12 @@ directoryServiceTests = do it "should NOT allow approving if roles are incorrect" testNotApprovedBadRoles describe "should require re-approval if profile is changed by" $ do it "the registration owner" testRegOwnerChangedProfile - it "another owner" testAnotherOwnerChangedProfile -- TODO fix - doesn't work if another owner is not connected as contact + it "another owner" testAnotherOwnerChangedProfile + it "another owner not connected to directory" testNotConnectedOwnerChangedProfile describe "should require profile update if group link is removed by " $ do it "the registration owner" testRegOwnerRemovedLink - it "another owner" testAnotherOwnerRemovedLink -- TODO fix - doesn't work if another owner is not connected as contact + it "another owner" testAnotherOwnerRemovedLink + it "another owner not connected to directory" testNotConnectedOwnerRemovedLink describe "duplicate groups (same display name and full name)" $ do it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration @@ -186,10 +189,13 @@ testDirectoryService ps = superUser #> "@SimpleX-Directory /approve 1:PSA 1" superUser <# "SimpleX-Directory> > /approve 1:PSA 1" superUser <## " Group approved!" - bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory!" + bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" - bob <## "Use /filter 1 to configure anti-spam filter and /role 1 to set default member role." + bob <## "Supported commands:" + bob <## "- /filter 1 - to configure anti-spam filter." + bob <## "- /role 1 - to set default member role." + bob <## "- /help commands - other commands." search bob "privacy" welcomeWithLink' search bob "security" welcomeWithLink' cath `connectVia` dsLink @@ -266,6 +272,38 @@ testDeleteGroup ps = bob <## " Your group privacy is deleted from the directory" groupNotFound bob "privacy" +testDeleteGroupAdmin :: HasCallStack => TestParams -> IO () +testDeleteGroupAdmin ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + cath `connectVia` dsLink + registerGroupId superUser cath "security" "Security" 2 1 + groupFound bob "privacy" + groupFound bob "security" + listUserGroup bob "privacy" "Privacy" + listUserGroup cath "security" "Security" + superUser #> "@SimpleX-Directory /last" + superUser <# "SimpleX-Directory> > /last" + superUser <## " 2 registered group(s)" + memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" + memberGroupListing superUser cath 2 "security" "Security" 2 "active" + -- trying to register group with the same name + submitGroup bob "security" "Security" + bob <# "SimpleX-Directory> The group security (Security) is already listed in the directory, please choose another name." + bob ##> "/d #security" + bob <## "#security: you deleted the group" + -- admin can delete the group + superUser #> "@SimpleX-Directory /delete 2:security" + superUser <# "SimpleX-Directory> > /delete 2:security" + superUser <## " The group security is deleted from the directory" + groupFound bob "privacy" + groupNotFound bob "security" + -- another user can register the group with the same name + registerGroupId superUser bob "security" "Security" 4 1 + testSetRole :: HasCallStack => TestParams -> IO () testSetRole ps = withDirectoryService ps $ \superUser dsLink -> @@ -726,13 +764,34 @@ testAnotherOwnerChangedProfile ps = cath <## "full name changed to: Privacy and Security" bob <## "cath updated group #privacy:" bob <## "full name changed to: Privacy and Security" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath!" bob <## "It is hidden from the directory until approved." groupNotFound cath "privacy" - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath." reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" +testNotConnectedOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testNotConnectedOwnerChangedProfile ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + bob `connectVia` dsLink + dan `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + cath ##> "/gp privacy privacy Privacy and Security" + cath <## "full name changed to: Privacy and Security" + bob <## "cath updated group #privacy:" + bob <## "full name changed to: Privacy and Security" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath!" + bob <## "It is hidden from the directory until approved." + groupNotFound dan "privacy" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath." + reapproveGroup 3 superUser bob + groupFoundN 3 dan "privacy" + testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () testRegOwnerRemovedLink ps = withDirectoryService ps $ \superUser dsLink -> @@ -758,14 +817,15 @@ testRegOwnerRemovedLink ps = cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" - bob ##> ("/set welcome #privacy " <> welcomeWithLink) + let withChangedLink = T.unpack $ T.replace "contact#/?v=2-7&" "contact#/?v=3-7&" $ T.pack welcomeWithLink + bob ##> ("/set welcome #privacy " <> withChangedLink) bob <## "description changed to:" - bob <## welcomeWithLink + bob <## withChangedLink bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." cath <## "bob updated group #privacy:" cath <## "description changed to:" - cath <## welcomeWithLink + cath <## withChangedLink reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" @@ -789,7 +849,7 @@ testAnotherOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "description changed to:" bob <## "Welcome!" - bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message." + bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message by cath." bob <## "" bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." @@ -800,20 +860,55 @@ testAnotherOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "description changed to:" bob <## welcomeWithLink - bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed." - bob <## "" - bob <## "Please update the group profile yourself." - bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") - bob <## "description changed to:" - bob <## (welcomeWithLink <> " - welcome!") - bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - cath <## "bob updated group #privacy:" - cath <## "description changed to:" - cath <## (welcomeWithLink <> " - welcome!") reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" +testNotConnectedOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testNotConnectedOwnerRemovedLink ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + bob `connectVia` dsLink + dan `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + bob ##> "/show welcome #privacy" + bob <## "Welcome message:" + welcomeWithLink <- getTermLine bob + cath ##> "/set welcome #privacy Welcome!" + cath <## "description changed to:" + cath <## "Welcome!" + bob <## "cath updated group #privacy:" + bob <## "description changed to:" + bob <## "Welcome!" + bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message by cath." + bob <## "" + bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." + superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + groupNotFound dan "privacy" + cath ##> ("/set welcome #privacy " <> welcomeWithLink) + cath <## "description changed to:" + cath <## welcomeWithLink + bob <## "cath updated group #privacy:" + bob <## "description changed to:" + bob <## welcomeWithLink + -- bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed." + -- bob <## "" + -- bob <## "Please update the group profile yourself." + -- bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") + -- bob <## "description changed to:" + -- bob <## (welcomeWithLink <> " - welcome!") + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." + bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." + -- cath <## "bob updated group #privacy:" + -- cath <## "description changed to:" + -- cath <## (welcomeWithLink <> " - welcome!") + reapproveGroup 3 superUser bob + groupFoundN 3 dan "privacy" + testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () testDuplicateAskConfirmation ps = withDirectoryService ps $ \superUser dsLink -> @@ -937,14 +1032,7 @@ testListUserGroups ps = cath <## "use @SimpleX-Directory to send messages" registerGroupId superUser bob "security" "Security" 2 2 registerGroupId superUser cath "anonymity" "Anonymity" 3 1 - cath #> "@SimpleX-Directory /list" - cath <# "SimpleX-Directory> > /list" - cath <## " 1 registered group(s)" - cath <# "SimpleX-Directory> 1. anonymity (Anonymity)" - cath <## "Welcome message:" - cath <##. "Link to join the group anonymity: " - cath <## "2 members" - cath <## "Status: active" + listUserGroup cath "anonymity" "Anonymity" -- with de-listed group groupFound cath "anonymity" cath ##> "/mr anonymity SimpleX-Directory member" @@ -1078,27 +1166,11 @@ testCaptcha _ps = do listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () listGroups superUser bob cath = do - bob #> "@SimpleX-Directory /list" - bob <# "SimpleX-Directory> > /list" - bob <## " 2 registered group(s)" - bob <# "SimpleX-Directory> 1. privacy (Privacy)" - bob <## "Welcome message:" - bob <##. "Link to join the group privacy: " - bob <## "3 members" - bob <## "Status: active" - bob <# "SimpleX-Directory> 2. security (Security)" - bob <## "Welcome message:" - bob <##. "Link to join the group security: " - bob <## "2 members" - bob <## "Status: active" - cath #> "@SimpleX-Directory /list" - cath <# "SimpleX-Directory> > /list" - cath <## " 1 registered group(s)" - cath <# "SimpleX-Directory> 1. anonymity (Anonymity)" - cath <## "Welcome message:" - cath <##. "Link to join the group anonymity: " - cath <## "2 members" - cath <## "Status: suspended because roles changed" + sendListCommand bob 2 + groupListing bob 1 "privacy" "Privacy" 3 "active" + groupListing bob 2 "security" "Security" 2 "active" + sendListCommand cath 1 + groupListing cath 1 "anonymity" "Anonymity" 2 "suspended because roles changed" -- superuser lists all groups bob #> "@SimpleX-Directory /last" bob <# "SimpleX-Directory> > /last" @@ -1106,34 +1178,42 @@ listGroups superUser bob cath = do superUser #> "@SimpleX-Directory /last" superUser <# "SimpleX-Directory> > /last" superUser <## " 3 registered group(s)" - superUser <# "SimpleX-Directory> 1. privacy (Privacy)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group privacy: " - superUser <## "Owner: bob" - superUser <## "3 members" - superUser <## "Status: active" - superUser <# "SimpleX-Directory> 2. security (Security)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group security: " - superUser <## "Owner: bob" - superUser <## "2 members" - superUser <## "Status: active" - superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group anonymity: " - superUser <## "Owner: cath" - superUser <## "2 members" - superUser <## "Status: suspended because roles changed" + memberGroupListing superUser bob 1 "privacy" "Privacy" 3 "active" + memberGroupListing superUser bob 2 "security" "Security" 2 "active" + memberGroupListing superUser cath 3 "anonymity" "Anonymity" 2 "suspended because roles changed" -- showing last 1 group superUser #> "@SimpleX-Directory /last 1" superUser <# "SimpleX-Directory> > /last 1" superUser <## " 3 registered group(s), showing the last 1" - superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)" - superUser <## "Welcome message:" - superUser <##. "Link to join the group anonymity: " - superUser <## "Owner: cath" - superUser <## "2 members" - superUser <## "Status: suspended because roles changed" + memberGroupListing superUser cath 3 "anonymity" "Anonymity" 2 "suspended because roles changed" + +listUserGroup :: HasCallStack => TestCC -> String -> String -> IO () +listUserGroup u n fn = do + sendListCommand u 1 + groupListing u 1 n fn 2 "active" + +sendListCommand :: HasCallStack => TestCC -> Int -> IO () +sendListCommand u count = do + u #> "@SimpleX-Directory /list" + u <# "SimpleX-Directory> > /list" + u <## (" " <> show count <> " registered group(s)") + +groupListing :: HasCallStack => TestCC -> Int -> String -> String -> Int -> String -> IO () +groupListing u = groupListing_ u Nothing + +memberGroupListing :: HasCallStack => TestCC -> TestCC -> Int -> String -> String -> Int -> String -> IO () +memberGroupListing su owner = groupListing_ su (Just owner) + +groupListing_ :: HasCallStack => TestCC -> Maybe TestCC -> Int -> String -> String -> Int -> String -> IO () +groupListing_ su owner_ gId n fn count status = do + su <# ("SimpleX-Directory> " <> show gId <> ". " <> n <> " (" <> fn <> ")") + su <## "Welcome message:" + su <##. ("Link to join the group " <> n <> ": ") + forM_ owner_ $ \owner -> do + ownerName <- userName owner + su <## ("Owner: " <> ownerName) + su <## (show count <> " members") + su <## ("Status: " <> status) reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () reapproveGroup count superUser bob = do @@ -1148,10 +1228,13 @@ reapproveGroup count superUser bob = do superUser #> "@SimpleX-Directory /approve 1:privacy 1" superUser <# "SimpleX-Directory> > /approve 1:privacy 1" superUser <## " Group approved!" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" - bob <## "Use /filter 1 to configure anti-spam filter and /role 1 to set default member role." + bob <## "Supported commands:" + bob <## "- /filter 1 - to configure anti-spam filter." + bob <## "- /role 1 - to set default member role." + bob <## "- /help commands - other commands." addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1295,10 +1378,13 @@ approveRegistrationId su u n gId ugId = do su #> ("@SimpleX-Directory " <> approve) su <# ("SimpleX-Directory> > " <> approve) su <## " Group approved!" - u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory!") + u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory - please moderate it!") u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." u <## "" - u <## ("Use /filter " <> show ugId <> " to configure anti-spam filter and /role " <> show ugId <> " to set default member role.") + u <## "Supported commands:" + u <## ("- /filter " <> show ugId <> " - to configure anti-spam filter.") + u <## ("- /role " <> show ugId <> " - to set default member role.") + u <## "- /help commands - other commands." connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index b360061ef0..b86f4c7d0d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -117,7 +117,9 @@ testCoreOpts = { dbConnstr = testDBConnstr, -- dbSchemaPrefix is not used in tests (except bot tests where it's redefined), -- instead different schema prefix is passed per client so that single test database is used - dbSchemaPrefix = "" + dbSchemaPrefix = "", + dbPoolSize = 3, + dbCreateSchema = True #else { dbFilePrefix = "./simplex_v1", -- dbFilePrefix is not used in tests (except bot tests where it's redefined) dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", @@ -184,6 +186,7 @@ testCfg = defaultChatConfig { agentConfig = testAgentCfg, showReceipts = False, + shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16 } @@ -288,8 +291,8 @@ startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") `runReaderT` cc - chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts - atomically . unless maintenance $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry + chatAsync <- async $ runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts + unless maintenance $ atomically $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput} @@ -391,14 +394,14 @@ withTmpFiles = testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () testChatN cfg opts ps test params = - bracket (getTestCCs (zip ps [1 ..]) []) entTests test + bracket (getTestCCs $ zip ps [1 ..]) endTests test where - getTestCCs :: [(Profile, Int)] -> [TestCC] -> IO [TestCC] - getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' tcs - entTests tcs = do - concurrentlyN_ $ map ( IO [TestCC] + getTestCCs [] = pure [] + getTestCCs ((p, db) : envs') = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' + endTests tcs = do + mapConcurrently_ ( TestCC -> Int -> Expectation ( ?" (Only msgIdBob) :: IO [[Int]] bobItemsCount `shouldBe` [[300]] + threadDelay 1000000 + testGetSetSMPServers :: HasCallStack => TestParams -> IO () testGetSetSMPServers = testChat aliceProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index bc0b0685df..fe933d5b98 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -101,6 +101,11 @@ chatProfileTests = do it "files & media" testGroupPrefsFilesForRole it "SimpleX links" testGroupPrefsSimplexLinksForRole it "set user, contact and group UI theme" testSetUITheme + describe "short links" $ do + it "should connect via one-time inviation" testShortLinkInvitation + it "should plan and connect via one-time inviation" testPlanShortLinkInvitation + it "should connect via contact address" testShortLinkContactAddress + it "should join group" testShortLinkJoinGroup testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = @@ -2583,3 +2588,162 @@ testSetUITheme = groupInfo a = do a <## "group ID: 1" a <## "current members: 1" + +testShortLinkInvitation :: HasCallStack => TestParams -> IO () +testShortLinkInvitation = + testChat2 aliceProfile bobProfile $ \alice bob -> do + alice ##> "/c short" + inv <- getShortInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + alice #> "@bob hi" + bob <# "alice> hi" + bob #> "@alice hey" + alice <# "bob> hey" + +testPlanShortLinkInvitation :: HasCallStack => TestParams -> IO () +testPlanShortLinkInvitation = + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + alice ##> "/c short" + inv <- getShortInvitation alice + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: own link" + alice ##> ("/_connect plan 1 " <> slSimplexScheme inv) + alice <## "invitation link: own link" + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + -- nobody else can connect + cath ##> ("/_connect plan 1 " <> inv) + cath <##. "error: connection authorization failed" + cath ##> ("/c " <> inv) + cath <##. "error: connection authorization failed" + -- bob can retry "plan" + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + -- with simplex: scheme too + bob ##> ("/_connect plan 1 " <> slSimplexScheme inv) + bob <## "invitation link: ok to connect" + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + alice #> "@bob hi" + bob <# "alice> hi" + bob #> "@alice hey" + alice <# "bob> hey" + bob ##> ("/_connect plan 1 " <> inv) + bob <##. "error: connection authorization failed" + alice ##> ("/_connect plan 1 " <> inv) + alice <##. "error: connection authorization failed" -- short_link_inv and conn_req_inv are removed after connection + +slSimplexScheme :: String -> String +slSimplexScheme sl = T.unpack $ T.replace "https://localhost/" "simplex:/" (T.pack sl) <> "?h=localhost" + +testShortLinkContactAddress :: HasCallStack => TestParams -> IO () +testShortLinkContactAddress = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + alice ##> "/ad short" + (shortLink, fullLink) <- getShortContactLink alice True + alice ##> ("/_connect plan 1 " <> shortLink) + alice <## "contact address: own address" + alice ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + alice <## "contact address: own address" + alice ##> ("/_connect plan 1 " <> fullLink) + alice <## "contact address: own address" + (alice, bob) `connectVia` shortLink + bob ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + (alice, cath) `connectVia` slSimplexScheme shortLink + cath ##> ("/_connect plan 1 " <> shortLink) + cath <## "contact address: known contact alice" + cath <## "use @alice to send messages" + (alice, dan) `connectVia` fullLink + where + (alice, cc) `connectVia` cLink = do + name <- userName cc + sName <- showName cc + cc ##> ("/_connect plan 1 " <> cLink) + cc <## "contact address: ok to connect" + cc ##> ("/c " <> cLink) + alice <#? cc + alice ##> ("/ac " <> name) + alice <## (sName <> ": accepting contact request, you can send messages to contact") + concurrently_ + (cc <## "alice (Alice): contact is connected") + (alice <## (sName <> ": contact is connected")) + cc ##> ("/_connect plan 1 " <> cLink) + cc <## "contact address: known contact alice" + cc <## "use @alice to send messages" + +testShortLinkJoinGroup :: HasCallStack => TestParams -> IO () +testShortLinkJoinGroup = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + threadDelay 100000 + alice ##> "/ad short" -- create the address to test that it can co-exist with group link + _ <- getShortContactLink alice True + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team short" + (shortLink, fullLink) <- getShortGroupLink alice "team" GRMember True + alice ##> ("/_connect plan 1 " <> shortLink) + alice <## "group link: own link for group #team" + alice ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + alice <## "group link: own link for group #team" + alice ##> ("/_connect plan 1 " <> fullLink) + alice <## "group link: own link for group #team" + joinGroup alice bob shortLink + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + bob ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + joinGroup alice cath $ slSimplexScheme shortLink + concurrentlyN_ + [ do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected", + cath <## "#team: member bob (Bob) is connected" + ] + cath ##> ("/_connect plan 1 " <> slSimplexScheme shortLink) + cath <## "group link: known group #team" + cath <## "use #team to send messages" + cath ##> ("/_connect plan 1 " <> shortLink) + cath <## "group link: known group #team" + cath <## "use #team to send messages" + joinGroup alice dan fullLink + concurrentlyN_ + [ do + bob <## "#team: alice added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected", + do + cath <## "#team: alice added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected", + do + dan <## "#team: member bob (Bob) is connected" + dan <## "#team: member cath (Catherine) is connected" + ] + dan ##> ("/_connect plan 1 " <> fullLink) + dan <## "group link: known group #team" + dan <## "use #team to send messages" + where + joinGroup alice cc link = do + name <- userName cc + sName <- showName cc + cc ##> ("/_connect plan 1 " <> link) + cc <## "group link: ok to connect" + cc ##> ("/c " <> link) + cc <## "connection request sent!" + alice <## (sName <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> name <> " joined the group"), + do + cc <## "#team: joining the group..." + cc <## "#team: you joined the group" + ] diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index b9068a2e21..38c8c308c8 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -11,7 +11,7 @@ module ChatTests.Utils where import ChatClient import ChatTests.DBUtils import Control.Concurrent (threadDelay) -import Control.Concurrent.Async (concurrently_) +import Control.Concurrent.Async (concurrently_, mapConcurrently_) import Control.Concurrent.STM import Control.Monad (unless, when) import Control.Monad.Except (runExceptT) @@ -427,7 +427,7 @@ getInAnyOrder f cc ls = do cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line (*<#) :: HasCallStack => [TestCC] -> String -> Expectation -ccs *<# line = concurrentlyN_ $ map (<# line) ccs +ccs *<# line = mapConcurrently_ (<# line) ccs (?<#) :: HasCallStack => TestCC -> String -> Expectation cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line @@ -505,14 +505,27 @@ dropPartialReceipt_ msg = case splitAt 2 msg of _ -> Nothing getInvitation :: HasCallStack => TestCC -> IO String -getInvitation cc = do +getInvitation = getInvitation_ False + +getShortInvitation :: HasCallStack => TestCC -> IO String +getShortInvitation = getInvitation_ True + +getInvitation_ :: HasCallStack => Bool -> TestCC -> IO String +getInvitation_ short cc = do cc <## "pass this invitation link to your contact (via another channel):" cc <## "" inv <- getTermLine cc cc <## "" cc <## "and ask them to connect: /c " + when short $ cc <##. "The invitation link for old clients: https://simplex.chat/invitation#" pure inv +getShortContactLink :: HasCallStack => TestCC -> Bool -> IO (String, String) +getShortContactLink cc created = do + shortLink <- getContactLink cc created + fullLink <- dropLinePrefix "The contact link for old clients: " =<< getTermLine cc + pure (shortLink, fullLink) + getContactLink :: HasCallStack => TestCC -> Bool -> IO String getContactLink cc created = do cc <## if created then "Your new chat address is created!" else "Your chat address:" @@ -525,6 +538,17 @@ getContactLink cc created = do cc <## "to delete it: /da (accepted contacts will remain connected)" pure link +dropLinePrefix :: String -> String -> IO String +dropLinePrefix line s + | line `isPrefixOf` s = pure $ drop (length line) s + | otherwise = error $ "expected to start from: " <> line <> ", got: " <> s + +getShortGroupLink :: HasCallStack => TestCC -> String -> GroupMemberRole -> Bool -> IO (String, String) +getShortGroupLink cc gName mRole created = do + shortLink <- getGroupLink cc gName mRole created + fullLink <- dropLinePrefix "The group link for old clients: " =<< getTermLine cc + pure (shortLink, fullLink) + getGroupLink :: HasCallStack => TestCC -> String -> GroupMemberRole -> Bool -> IO String getGroupLink cc gName mRole created = do cc <## if created then "Group link is created!" else "Group link:" diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index ec4e336fe9..fc872f05b1 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -8,7 +8,9 @@ module MarkdownTests where import Data.List.NonEmpty (NonEmpty) import Data.Text (Text) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Messaging.Encoding.String import System.Console.ANSI.Types import Test.Hspec @@ -169,7 +171,12 @@ uri :: Text -> Markdown uri = Markdown $ Just Uri simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown -simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, smpHosts} +simplexLink linkType uriText smpHosts t = Markdown (simplexLinkFormat linkType uriText smpHosts) t + +simplexLinkFormat :: SimplexLinkType -> Text -> NonEmpty Text -> Maybe Format +simplexLinkFormat linkType uriText smpHosts = case strDecode $ encodeUtf8 uriText of + Right simplexUri -> Just SimplexLink {linkType, simplexUri, smpHosts} + Left e -> error e textWithUri :: Spec textWithUri = describe "text with Uri" do @@ -275,6 +282,6 @@ multilineMarkdownList = describe "multiline markdown" do it "multiline with simplex link" do ("https://simplex.chat" <> inv <> "\ntext") <<==>> - [ FormattedText (Just $ SimplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), + [ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), "\ntext" ] diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 0a00d7b83c..dbfde6a03d 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -19,6 +19,7 @@ import qualified Data.List.NonEmpty as L import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Operators +import Simplex.Chat.Operators.Presets import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index ab2b4b0af2..a54a9dd36e 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -35,7 +35,7 @@ queue = { smpServer = srv, senderId = EntityId "\223\142z\251", dhPublicKey = "MCowBQYDK2VuAyEAjiswwI3O/NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o=", - sndSecure = False + queueMode = Nothing } connReqData :: ConnReqUriData @@ -201,7 +201,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -210,7 +210,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -237,10 +237,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -261,16 +261,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-15\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-15\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -291,10 +291,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") it "x.grp.direct.inv without content" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing -- it "x.grp.msg.forward" -- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" diff --git a/website/src/.well-known/apple-app-site-association/index.json b/website/src/.well-known/apple-app-site-association/index.json index 3cd3fdd043..3b513fe61e 100644 --- a/website/src/.well-known/apple-app-site-association/index.json +++ b/website/src/.well-known/apple-app-site-association/index.json @@ -17,6 +17,30 @@ }, { "/": "/invitation" + }, + { + "/": "/a/*" + }, + { + "/": "/a" + }, + { + "/": "/c/*" + }, + { + "/": "/c" + }, + { + "/": "/g/*" + }, + { + "/": "/g" + }, + { + "/": "/i/*" + }, + { + "/": "/i" } ] }